Комментарии про методы входа приведены к факту (логин/пароль и Telegram везде,
stub только в development): config.py, .env.example, telegram.py, dev_stub.py,
dev_auth.py, login.py. admin_login.py больше не обещает «строже игроцкого» —
описаны реальные лимиты; client_ip — адрес за прокси уже даёт uvicorn.
Подсказки деплоя: run.ps1/run.sh (build-push на ПК, `up -d` на Pi), compose,
.env.example (build-push.ps1), Caddyfile (туннель-контейнер вместо autossh);
ratelimit.py ссылается на core/events.py вместо CLAUDE.md.
Удалены неиспользуемые scripts/export-prod.sh (с блоком export-ignore в
.gitattributes и упоминаниями в README), db/init_db.py и случайно закоммиченный
файл «h -u origin dev…». #70
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
APP_TZ_OFFSET_HOURS влиял только на «дату игры», а фронт показывал время в
захардкоженных +3 — при другом значении дата и время партии противоречили друг
другу. Теперь /api/auth/config отдаёт tz_offset_hours, App.tsx выставляет его в
format.ts до первой отрисовки страниц, и в нём же показывается всё время и
вводятся даты объявлений (запасное значение +3, если конфиг недоступен). Пояс
устройства не учитывается — решение владельца. Валидатор ограничивает смещение
−12..14. #68
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
Рейтинг с #80 один на всё приложение: завершённая или удалённая партия двигает
топ, историю и профили всех, кто играл после неё, и страницы других групп.
Событие match по-прежнему идёт группе партии, остальным активным игрокам —
ratings без подробностей о партии. Фронт по обоим сбрасывает все рейтинговые
витрины (invalidateRatingViews); participant_ids больше не нужен. #88
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
Объявление администрации показывается игроку окном в свой период, пока игрок
не закроет его («Понятно»). Отметка о закрытии хранится на сервере с номером
версии: правка с «показать заново» поднимает версию, и закрывшие прежнюю
увидят объявление снова — ответ помечен updated («обновлено»). Флаг
show_to_new_players=false прячет объявление от зарегистрировавшихся после
начала показа. Пересекающиеся объявления идут от старого к новому.
Текст приходит HTML-ом из редактора админки и сохраняется только после
очистки по белому списку (b, em, mark и mark.red, p, br): атрибуты
отбрасываются, script/style/svg — вместе с содержимым, текст экранируется
заново. Фронт вставляет только этот HTML.
API: GET /api/announcements/pending, POST /api/announcements/{id}/ack;
админка — список со статусом и счётчиком «закрыли N из M», создание, правка,
«снять с показа», удаление, всё в аудит. SSE-событие announcements активным
игрокам. Миграция 0015 идемпотентная.
Тесты: очистка (XSS-попытки, вложенные div), права, период и порядок,
«новые игроки», повторный показ, снятие, удаление, валидация. #84
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Модели и миграция 0014: groups.nine_rounds_rule, снимок правила в matches,
matches.end_round, match_participants.objectives/worlds. CHECK причины победы
расширяется, только если он в БД есть (старые БД без CHECK таблицу не
пересоздают); пересоздание matches отказывает при PRAGMA foreign_keys=ON,
иначе DROP унёс бы участников каскадом. Бэкфилл: last_standing у завершённых
партий с одним невыбывшим. Проверено на копии dev-БД и на схеме origin/dev
с CHECK: строки, CHECK, FK и индексы на месте, повтор и downgrade работают.
API и валидация: end_round от 1 до лимита раундов партии (9 при хоумруле
и 5+ игроках), у выбывшего миров 0, last_standing ровно при одном невыбывшем —
в завершении и правке (игрока и админа); черновик хранит раунд, цели и миры
без проверки правила. MatchRead отдаёт end_round, снимок правила и max_rounds.
PATCH /groups/{id} принимает nine_rounds_rule вместе с названием. #23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
throttle_register ограничивает частоту POST /api/auth/register с одного IP
через тот же LoginThrottle (считаются все попытки). Enumeration ников через
409 не закрываем — ники и так публичны в топе (отмечено в отчёте). #62
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
admin_login проходит через LoginThrottle (пара IP+логин, IP и сам аккаунт),
как вход игрока; исчерпание лимита -> 429. Неудачные попытки пишутся в аудит
(без пароля). Пароль админа — прямой путь к полному контролю, лимиты строже. #56
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
logout отзывает предъявленный токен по jti (in-memory denylist до exp);
смена и сброс пароля инкрементят users.token_version (claim ver в JWT,
сверка в auth/deps) — все прежние сессии отзываются. Своё устройство при
смене пароля остаётся в сессии (перевыдача cookie). Миграция 0013. #57
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
PUT /api/users/me/password задаёт или меняет пароль. Первый раз текущий пароль
не нужен: так его задают аккаунты из Telegram и все, кто появился до паролей.
Если пароль уже есть, нужен текущий. Иначе оставленная открытой сессия позволила
бы отобрать аккаунт насовсем, поэтому подбор текущего тоже ограничен: 5 неудач
на аккаунт за 15 минут. Ошибка 403 WRONG_CURRENT_PASSWORD, а не 401, чтобы фронт
не принял её за истёкшую сессию.
POST /api/users/me/telegram привязывает Telegram к аккаунту, созданному по
паролю. Подпись виджета проверяется так же, как при входе, ник не меняется.
Связка пишется в auth_identity, как при регистрации через Telegram, поэтому
следующий вход через Telegram попадает в этот аккаунт. Telegram, привязанный к
другому аккаунту, даёт 409 TELEGRAM_TAKEN, повторная привязка — 409
TELEGRAM_ALREADY_LINKED.
PUT /api/admin/users/{id}/password — способ восстановить забытый пароль: почту
приложение не хранит. Работает только для игроков, пароль админа по-прежнему
задаётся в .env. В аудит пишется только факт смены, без пароля. Сборка
AdminUserRead вынесена в хелпер, в ответе появилось has_password.
#24
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
Игроки входили только через Telegram, а вход по нику без пароля (stub) есть лишь
в dev. Теперь основной вход во всех окружениях: POST /api/auth/register и
POST /api/auth/login, логин — это ник. Stub в прод не переносится: он пускает без
секрета и по-прежнему живёт только в dev. Новый код лежит в прод-модуле
auth/password.py и dev-модули не импортирует. Схема БД не меняется: колонка
password_hash и провайдер local есть с первой миграции, на них построен вход
админа.
Пароль от 8 символов и не длиннее 72 байт: дальше bcrypt 5 бросает ValueError.
Схема API режет тело длиннее 128 символов ещё до bcrypt. Игроком входит только
role='player', так что учётка админа не открывает сессию игрока, и наоборот.
Неизвестный логин и аккаунт без пароля сверяются с фиктивным хешем и получают ту
же 401 INVALID_CREDENTIALS: по ответу и его времени нельзя понять, есть ли логин.
От перебора — скользящее окно 15 минут в памяти процесса (рассчитано на один
воркер, как SSE-шина): 5 неудач на пару «IP + логин» и 20 на IP, дальше 429
TOO_MANY_ATTEMPTS с retry_after. Пока блок стоит, пароль не проверяется вовсе.
Успешный вход сбрасывает счётчик пары, но не IP.
В MeRead появилось has_password: по нему фронт попросит задать пароль тех, у
кого его нет. Метод password добавлен в /auth/config.
#24
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
Событие о партии рассылается всей группе, и клиент по нему инвалидировал историю
игр, публичные профили и личную статистику получателя. В группе из шести человек
любая партия двоих заставляла остальные четыре вкладки перезапрашивать свою
историю и открытый профиль, хотя у них ничего не изменилось.
Теперь событие несёт participant_ids. Общие витрины (карточка партии, списки и
статистика группы, топ и главная) обновляются у всех — рейтинг глобальный, чужая
партия действительно двигает топ. История, публичный профиль и личная статистика
обновляются только у тех, кто играл, и у зрителей их профилей.
При удалении партии участники собираются ДО удаления: каскад уносит их строки
вместе с партией, и собранный после список всегда был бы пустым. Это же
поведение закреплено тестом.
Событие без participant_ids (вкладка открыта до обновления сервера) обрабатывается
по-старому, широко: обновление бэкенда не ломает уже открытые страницы.
#33
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
Результаты (места и причина победы) теперь пишутся только в завершённую партию.
Раньше их можно было проставить идущей: партия оставалась in_progress, висела в
«Незавершённых», в статистику не попадала и очков не приносила — победитель есть,
а игры как бы не было. Дату и общий комментарий по ходу партии править по-прежнему
можно: двойственного состояния они не создают.
Форма завершения получила общий черновик (match_finish_drafts): раскладка мест,
ничьи, выбывшие, комментарии и причина победы видны всем, кто заполняет партию.
Отдельная таблица, а не колонки в matches, намеренно — запись в строку партии
дёргает onupdate у updated_at, то есть версию для оптимистичной блокировки, и
«Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку.
Черновик удаляется при завершении и уходит каскадом при удалении партии.
Черновик разъезжается отдельным типом SSE-события: он меняется на каждое движение
тайла, и полная инвалидация (лидерборд, история, профили) по нему была бы
расточительной. Автору правки событие не шлётся.
#28
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
- IP клиента для аудита брался инлайном в 19 местах шести модулей; теперь
security.client_ip — за привратником адрес придётся читать из
X-Forwarded-For, и одна точка правки для этого обязательна.
- Чтение загруженной картинки (лимит размера + sniff формата) было скопировано
в четыре обработчика; вынесено в user_service.read_capped_image.
- Лимит размера вложения жил двумя одинаковыми константами в игроцком и
админском роутере — перенесён к самим вложениям.
- update_nickname и set_active_group переиспользуют nickname_format_ok и
group_service.get_membership вместо собственных копий проверки.
- Убраны осиротевшие импорты и комментарий-заготовка о вложениях, которые
давно реализованы (MatchAttachment).
#8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
Находки прохода /code-review high по backend/app:
- achievement_service: slug из URL шёл в путь без проверки, из-за чего
DELETE /api/admin/achievements/%2E%2E удалял rmtree'ом родительскую папку
каталога ачивок (в проде это /data — БД, uploads, ачивки целиком).
- match_service/attachment_service: версия партии = updated_at, но onupdate
срабатывает лишь при реальном UPDATE строки matches. Правка одних участников
и работа с вложениями его не вызывали, и оптимистичная блокировка молча
пропускала конкурентную запись — бампаем updated_at явно.
- admin_service: удаление группы с партиями упиралось в RESTRICT и уходило
наружу голым 500; теперь понятная ошибка. Админское удаление партии не
чистило файлы вложений с тома — они оставались навсегда.
- user_service: при повторной загрузке аватара с тем же расширением avatar_path
не менялся, updated_at не двигался, и кэш-бастер оставлял старую картинку до
часа. Плюс версия считалась из наивного времени как из локального и
разъезжалась с лидербордом, где то же поле считает SQL.
- membership_service: единственный владелец мог разжаловать сам себя и группа
оставалась без владельца навсегда.
- notification_service: mark_read не слал SSE-сигнал, и бейдж непрочитанных на
других устройствах висел до перезагрузки.
- routers/admin: created_at после правки пользователя отдавался без смещения,
и дата «создан» прыгала на часовой пояс до следующего обновления списка.
#8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
Выборки партий по игроку в бэкенде не было — только по группе. Добавлен
stats_service.user_match_list: завершённые партии игрока, свежие сверху;
сборка элементов вынесена из group_match_list в общий _match_items, чтобы
не дублировать её в двух местах.
Витрина профиля задаётся двумя колонками в users (миграция 0011):
history_mode (all/best) и history_detail (compact/full). Режим применяется
на бэкенде, а не на клиенте: это витрина владельца, и в том же виде
профиль видят гости. В режиме best берётся партия с максимальными League
Points из SCORED_CTE (при равных очках — более свежая).
GET /api/users/{user_id}/matches отдаёт список вместе с mode и detail —
гостю хватает одного запроса, чтобы отрисовать историю как задумал владелец.
#1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Раньше «любимая» вычислялась как самая частая среди партий с ручным
выбором фракции. Теперь это поле профиля: users.favorite_faction_id
(FK на factions, ON DELETE SET NULL, миграция 0010) и необязательное
поле в PATCH /api/users/me/profile.
Роутер профиля переведён на model_dump(exclude_unset=True) и обновляет
только пришедшие поля — иначе сохранение одного «о себе» обнуляло бы
выбранную фракцию.
«Основная фракция» логику не меняет (она и так считалась по всем партиям,
включая рандомные раздачи) и переезжает в подпись «Чаще всего играет на»:
FactionStat получает name_ru_prepositional из словаря предложного падежа
в faction_service. Словарь в коде, а не в БД: набор фракций задан игрой;
фракция вне словаря выводится в именительном.
Служебное chosen_games существовало только ради старой «любимой» — убрано
из SQL вместе с ней.
#17
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk
Раньше уведомления удалялись через 72 часа после создания независимо от
прочтения: кто не зашёл за 3 дня, не видел их вовсе. Теперь по времени
чистятся только прочитанные (72ч после прочтения), а от роста у
незаходящих защищает кап MAX_PER_USER=100 — при создании удаляются
старейшие сверх лимита. Механика доставки не менялась: запись в БД +
pull при заходе, SSE лишь ускоряет обновление открытых вкладок.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Вместо нативного select — кнопка-триггер (аватар + ник) с выпадающим
списком всех игроков группы в стилистике подсказок инвайта; игроки,
уже выбранные в других строках, не предлагаются (заодно исключены
дубли). Для аватаров в списке MemberRead теперь отдаёт avatar_url.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
До 10 подсказок по подстроке ника среди активных игроков (role=player).
Регистронезависимо и для кириллицы: SQLite LIKE/lower сворачивают регистр
только у ASCII, поэтому фильтрация/ранжирование в Python (casefold):
префиксные совпадения раньше подстрочных, внутри — по алфавиту.
Пустой q — пустой список. Тесты + регенерированный schema.d.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Выбор места при завершении партии получает опцию «✕ выбыл»; выбывшему
авто-проставляется общее последнее место (ничья последнего ранга), плюс флаг
eliminated для отрисовки красным крестиком. В результатах и списке партий
выбывшие показываются крестиком и идут в конец. Подсчёт очков не меняется
(используются обычные числовые места).
Бэкенд: модель MatchParticipant.eliminated + миграция 0009 (идемпотентная);
схемы (place стал необязательным, +eliminated); match_service (хелпер
_resolve_finish_places, finish/update); проброс eliminated в роутерах и stats.
Фронт: выбор места с крестиком в MatchDetailPage и AdminMatchEdit, отрисовка в
результатах и MatchList, стиль .place-badge.eliminated; регенерация schema.d.ts.
Тесты: завершение с выбывшим (последнее место, засчитан), все-выбывшие → 422.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Новая система уведомлений поверх готовой SSE-шины: события пишутся в БД,
живут 72 часа и чистятся (при чтении списка + фоновой задачей), всплывают
сверху экрана в момент прихода, доступны через колокольчик в правом верхнем
углу (бейдж непрочитанных + панель).
Типы: приглашение в группу (→ /group), старт/финиш партии участникам кроме
инициатора (→ /match/{id}). Титулы — готовый хелпер-задел (не подключён, т.к.
выдача титулов игрокам ещё не реализована).
Бэкенд: модель Notification + миграция 0008 (идемпотентная), notification_service,
notify.notifications_changed, роутер /api/notifications (GET + /read), триггеры
в groups/matches, фоновая чистка в lifespan, защита hub.publish от закрытого loop.
Фронт: useNotifications/useMarkNotificationsRead, NotificationBell/Panel/Toaster,
перекомпоновка top-bar, стили; useServerEvents знает тип notifications.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>