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
Вход игрока и админа получают лимит login-user/admin-login-user, не зависящий
от IP: ротация X-Forwarded-For (#58) больше не снимает защиту полностью.
Успешный вход сбрасывает счётчики аккаунта. В docstring ratelimit — про сброс
при рестарте и необходимость внешнего стора при нескольких воркерах. #60
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
Многие тосты повторяли то, что пользователь и так видит сразу после действия:
новую фракцию или аватар в профиле, переход на страницу партии, исчезнувшую
карточку приглашения, закрывшийся редактор. Такой шум приучает не читать
всплывашки, в том числе ошибки. Убраны 19 таких тостов в профиле, группах,
партиях и админке.
Остались ошибки, предупреждения (лимит фото, повторы фракций, конфликт правок,
«вы не в группе») и сообщения о действиях без видимого следа: приглашение
отправлено, дополнения сохранены, вступление в группу, пароль задан. Сохранение
текстового поля, где заметна только погасшая кнопка, тоже осталось.
По просьбе владельца оставшиеся тосты показываются не нижней «таблеткой», а
всплывашкой сверху, как входящие уведомления. ToastProvider держит одну стопку
на всё приложение: show — обычное сообщение, error — ошибка с красной полосой
(держится 5 с), notify — уведомление с переходом по клику. Одинаковое сообщение
не копится, а заменяет прежнее; клик по карточке закрывает её.
NotificationToaster больше не рисует свою стопку, а отдаёт новые уведомления в
общую, поэтому они не наезжают на сообщения. Все вызовы с ApiError и отказы
(лимит фото, партия изменилась на другом устройстве) переведены на toast.error.
Нижний класс .toast удалён.
#41
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
Если JSON-эндпойнт получал тело с Content-Type text/plain (так шлёт HTML-форма),
сервер отвечал 500. _validation_handler клал exc.errors() в details как есть, а в
input там лежало сырое тело типа bytes, которое JSONResponse не сериализует.
Затронуты все эндпойнты с телом, в том числе /api/auth/telegram и
/api/admin/auth/login. Запрос и раньше отклонялся, сессия не создавалась, но
вместо понятной 422 в логе появлялся трейсбек.
Тот же input возвращал клиенту тело запроса целиком. Неполная форма входа
отправляла пароль обратно в ответе 422, откуда он мог осесть в логах прокси и
инструментах отладки.
Теперь в details идут только type, loc и msg. input и ctx (там бывают объекты
исключений — та же проблема сериализации) в ответ не попадают. Конверт и код
VALIDATION_ERROR прежние, фронт details валидации не читает.
Тесты: text/plain на двух эндпойнтах даёт 422, пароль не возвращается, loc
по-прежнему указывает на поле. На старом коде первые два падают.
#52
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
В таблицах методов входа (раздел «Аутентификация» и сводка окружений) пароль
теперь основной способ во всех окружениях, Telegram — второй, stub остаётся
только в dev. Описаны правила пароля, защита от перебора, обязательное окно для
аккаунтов без пароля, привязка Telegram и восстановление пароля через админку.
Поправлены места, где test-клон описывался как «только Telegram».
#24
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
Страница входа начинается с карточки «Вход» (ник и пароль), в ней же
переключатель на регистрацию с повтором пароля. Telegram стоит ниже вторым
способом. Под ним подсказка: аккаунт, созданный по паролю, сначала привязывают к
Telegram в профиле, иначе вход через Telegram заведёт новый аккаунт. Поле логина
без автозаглавной буквы и автоисправления, у полей правильный autocomplete для
менеджеров паролей.
SetPasswordDialog показывается в AppShell всем, у кого has_password=false:
аккаунтам из Telegram и созданным до паролей. Закрыть окно нельзя, только задать
пароль или выйти. Успешный ответ кладёт в кэш профиль с has_password=true, и окно
исчезает само. Кнопка «Позже (dev)» есть лишь под import.meta.env.DEV: в
прод-бандле её строки нет, это проверено по dist.
В профиле появилась карточка «Вход в аккаунт»: смена пароля (текущий, если он
задан, новый и повтор) и привязка Telegram тем же виджетом, что при входе. У
смены ника подсказка, что ник — это и логин. В админке у игрока появилась кнопка
«Задать пароль» с вводом в той же строке, как у переименования, а в строке
игрока — пометка «без пароля».
schema.d.ts перегенерирован с живого бэкенда.
#24
Co-Authored-By: Claude Opus 5 <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
На проде любые изменения данных, включая выход из аккаунта, отвечали 403
CSRF_FAILED, хотя сайт оставался залогиненным. Cookie csrf_token одна на игрока
и админку: вход в админку перезаписывал её со сроком 8 часов, а сессия игрока
живёт 7 дней. Когда токен истекал, сервер выдавал новый только при входе, а войти
и выйти мешала та же проверка. Из этого состояния было не выбраться, кроме как
стереть cookie сайта руками.
Теперь CSRFMiddleware перевыдаёт токен на любом ответе /api, если запрос несёт
сессионную cookie без csrf_token, в том числе на самом отказе. SPA на загрузке
делает GET /api/users/me, поэтому пользователю хватает перезагрузить страницу.
Правится только стартовое сообщение ответа, тело идёт насквозь, и SSE-поток
не буферизуется. Срок токена теперь не короче самой долгой сессии, так что вход
в админку больше не укорачивает токен игрока.
Проверка double-submit не ослаблена: запрос с cookie, но без заголовка или с
чужим токеном по-прежнему получает 403, и cookie в этом случае не перевыдаётся.
Перевыданный токен из кросс-сайтового ответа атакующему ничего не даёт: прочитать
cookie может только JS того же origin.
Тесты закрепляют восстановление на GET и на отказе, срок после входа в админку,
прежнюю строгость проверки, отсутствие токена у анонимов и то, что middleware
не склеивает чанки потока. На старом коде четыре из них падают.
#50
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
На Pi лежат только docker-compose.yml и .env, исходников для сборки там нет, поэтому
подсказка "docker compose up -d --build" падала с "unable to prepare context".
Образы собираются и пушатся с ПК скриптом scripts/build-push.ps1, а Pi их просто
подтягивает.
Правка владельца, перенесена в ветку задачи #50.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
После запуска dev сайт уходил в вечную загрузку без единой ошибки в логе. Открытая
вкладка держит SSE-поток /api/events, и сервер его сам не закрывает. При любой правке
.py (pull, переключение ветки, мёрж) uvicorn --reload останавливает старый процесс,
а тот в graceful shutdown ждёт закрытия всех соединений. Лимита по умолчанию нет,
поэтому ожидание длится вечно: новый процесс не стартует, слушающий сокет остаётся
у reloader'а, соединения принимаются в backlog и никем не обслуживаются. Запрос
/api/users/me висит, RequireAuth крутит спиннер, в логе только "Reloading...".
Воспроизведено тем же способом, каким запускает run.ps1 (uvicorn в отдельном окне):
при открытом SSE и тронутом .py /api/health не отвечал, хотя TCP-соединение
устанавливалось за 12 мс. Сервер ожил ровно в момент закрытия SSE.
Теперь uvicorn запускается с --timeout-graceful-shutdown: по истечении лимита он
отменяет висящие задачи запросов и доводит перезапуск до конца. В dev лимит 2 с, и
тот же сценарий отвечает 200 примерно через 4 с после правки файла. В entrypoint
лимит 10 с: в контейнере тот же механизм держал остановку до SIGKILL по
stop_grace_period (30 с), и lifespan-shutdown не выполнялся. Команды ручного запуска
в README дополнены тем же флагом.
#47
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
Накат миграций из #36 обрывал запуск dev на Windows. Alembic пишет свои INFO в
stderr, а Windows PowerShell 5.1 при любом перенаправлении stderr нативного exe
превращает каждую строку в NativeCommandError. В начале скрипта стоит
$ErrorActionPreference = "Stop", поэтому ошибка терминирующая: лаунчер умирал
прямо на миграциях, uvicorn и vite не стартовали.
Комментарий в прежнем коде предупреждал про эту ловушку у "2>&1", но перенаправление
в файл наступает на неё ровно так же — проверено воспроизведением, управление
уходит в catch.
Теперь alembic запускается через Start-Process с редиректом обоих потоков в файлы:
его stderr вообще не проходит через поток ошибок PowerShell, решение принимается по
ExitCode. Оба сценария проверены в тех же условиях (ErrorActionPreference = Stop):
обычное применение — код 0, база новее ветки — распознаётся и запуск продолжается.
#45
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
Событие о партии рассылается всей группе, и клиент по нему инвалидировал историю
игр, публичные профили и личную статистику получателя. В группе из шести человек
любая партия двоих заставляла остальные четыре вкладки перезапрашивать свою
историю и открытый профиль, хотя у них ничего не изменилось.
Теперь событие несёт participant_ids. Общие витрины (карточка партии, списки и
статистика группы, топ и главная) обновляются у всех — рейтинг глобальный, чужая
партия действительно двигает топ. История, публичный профиль и личная статистика
обновляются только у тех, кто играл, и у зрителей их профилей.
При удалении партии участники собираются ДО удаления: каскад уносит их строки
вместе с партией, и собранный после список всегда был бы пустым. Это же
поведение закреплено тестом.
Событие без participant_ids (вкладка открыта до обновления сервера) обрабатывается
по-старому, широко: обновление бэкенда не ломает уже открытые страницы.
#33
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
Главная гоняла тяжёлый SCORED_CTE пять раз: лидерборд, три запроса профиля (итог,
разбивка по фракциям, форма последних партий) и ещё раз итог для активной группы.
Четыре последних выбирали одни и те же строки одного игрока и отличались только
агрегацией, а стоимость CTE растёт с числом партий во всём приложении, а не в
группе игрока.
Теперь строки игрока тянутся одним запросом, а итог, разбивка и форма считаются из
них в Python. Фильтр по группе — фильтрация того же набора, поэтому блок активной
группы не стоит отдельного прохода. На главной осталось два прохода вместо пяти,
у профиля — один вместо трёх.
Формула сглаженного рейтинга получила Python-версию рядом с SQL-версией, на тех же
константах: в SQL она нужна лидерборду, где агрегация идёт по всем игрокам. Чтобы
две реализации не разъехались (как однажды вышло с кэш-бастером аватара), добавлен
тест, сверяющий цифры профиля с цифрами того же игрока в лидерборде.
Второй тест считает запросы с SCORED_CTE на главной: без него оптимизация тихо
отъедет назад при следующей правке витрин.
#32
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
История запрашивалась без limit/offset, поэтому бэкенд отдавал первые 20 партий, а
страницы рисовали их и молча игнорировали total: у игрока с 40 партиями половина
истории просто не существовала — ни кнопки, ни намёка.
Теперь история грузится страницами по 20, под списком — «Показать ещё». Постранично,
а не одним большим запросом: у эндпоинта потолок limit=100, и на 101-й партии
обрезка вернулась бы. Кнопка живёт в самом компоненте истории, поэтому появилась
сразу и в своём профиле, и в чужом, и в обоих режимах подробности.
Общее число партий вынесено в шапку профиля, к нику. Берём его из статистики, а не
из total истории: в режиме «только лучшая партия» total равен единице, и счётчик
показывал бы «Партий: 1».
#30
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
Тот, кто был в форме завершения, но не нажимал «Завершить», получал сразу два
сообщения об одном событии: штатное уведомление сайта сверху (NotificationToaster
по match_finished) и технический тост снизу. Нижний убран — сверху сказано то же
самое, и там на него можно нажать, чтобы перейти к партии.
Вместе с тостом ушли refs, которые обслуживали только его.
#38
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
Правка завершённой партии проверяла состав теми же правилами, что и создание:
участник обязан состоять в группе сейчас, фракция — быть доступной сейчас. После
отключения дополнения партию, сыгранную на Тау, было уже не исправить, а после
удаления игрока из группы — любую партию с ним. Теперь то, что уже записано в
партии, проходит всегда, а новые игроки и фракции по-прежнему берутся только из
текущего состава: чинить историю можно, занести в неё постороннего — нет.
Создание партии не ослабло.
Править завершённую партию умел любой участник группы, но только через API —
кнопки не было, и на практике это мог сделать лишь админ через админку. Теперь у
блока «Результаты» есть «Редактировать», и правка идёт тем же перетаскиванием,
что и завершение: раскладка восстанавливается из сохранённых мест (одинаковое
место — ничья, выбывшие отдельно), рядом — фракции, причина победы и комментарии.
Сохранение шлёт версию партии, так что устаревшая правка отклоняется как раньше.
Фракции вынесены отдельным блоком, а не в PlaceEditor: он занят перетаскиванием,
и селекты внутри него — лишний риск. В списке доступных фракций к набору группы
добавляются те, что уже стоят в партии, — иначе фракцию из отключённого
дополнения нельзя было бы даже оставить как есть.
#29
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
Ветка задачи может принести миграцию, а run.ps1/run.sh сразу запускали uvicorn:
приложение поднималось на старой схеме и падало 500 на первом обращении к новой
таблице. Так вышло с 0012_match_finish_draft — создание партии отвечало «no such
table: match_finish_drafts». В test/prod такого нет, там схему накатывает
entrypoint.sh контейнера.
Отдельно разобран случай «база новее ветки»: после возврата с ветки задачи alembic
не находит ревизию, которой в этой ветке ещё нет. Останавливать запуск тут не за
что — лишние таблицы старому коду не мешают, поэтому печатаем предупреждение и
идём дальше. Настоящая ошибка миграции по-прежнему останавливает запуск.
stderr alembic в run.ps1 уводится в файл: в Windows PowerShell 5.1 «2>&1» на
нативном exe заворачивает каждую строку в ErrorRecord, а решение принимается по
коду возврата. run.ps1 остался строго ASCII.
#36
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
Раскладка, комментарии, причина победы и комментарий о партии уходят в общий
черновик через ~0.6с после последнего действия, а чужие правки подтягиваются по
SSE. Чужой черновик применяется, только если с момента моего последнего действия
прошло больше 1.5с — иначе правка соседа перетирала бы тайл прямо под рукой.
Перед «Завершить» отложенная запись дожимается: иначе последняя правка попала бы
в результаты, но не в черновик, и второй участник увидел бы не то, что записалось.
Само завершение по-прежнему шлёт тело запроса, так что работает и без черновика.
Под подсказкой о перетаскивании появилась строка «Результаты заполняет также
<Ник>», а если партию завершил кто-то другой — тост вместо молча исчезающей формы.
В админке у идущей партии вместо формы правки — пояснение и переход на страницу
партии: сервер её результаты всё равно не примет.
#28
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
- Четыре копии «ресайз → FormData → CSRF из cookie → fetch → разбор конверта
ошибки» (аватар, фото партии, фото из админки, иконка ачивки) сведены в
lib/upload.ts. Иконка по-прежнему уходит оригиналом: ресайз в JPEG убил бы
прозрачность герба.
- Три копии readCsrfToken и вторая копия resizeImage удалены — берём readCookie
из api/client.ts и resizeImage из lib/image.ts с параметром размера.
- Ключи, протухающие от партии, собраны в matchAffectedKeys: раньше они были
написаны строками мимо реестра qk в двух местах, и переименование ключа
сломало бы инвалидацию молча.
- Различение «нет сессии» и «нет связи» вынесено в authProbeRetry и применено к
обеим пробам. Прошлый заход чинил только игроцкие гварды, и RequireAdmin
по-прежнему выкидывал админа на страницу входа при обрыве связи.
- ToastContext больше не пересоздаёт значение контекста: провайдер обёрнут
вокруг всего приложения, и каждый тост перерисовывал всех потребителей.
- PlaceEditor не трогает DOM, пока цель подсветки не изменилась (было
querySelectorAll на каждый pointermove).
#8
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
Проход /simplify по backend/app:
- Три источника «текущего времени» (_utcnow в models.py и match_service.py при
живом timeutil.utcnow) сведены к одному — это прямо инвариант из CLAUDE.md.
- Бамп версии партии из двух независимых мест собран в match_service.touch:
следующая точка мутации, не трогающая строку matches, теперь имеет очевидный
способ сделать правильно.
- membership_service переиспользует group_service.get_membership вместо трёх
копий одного запроса; защита последнего владельца — один хелпер на удаление
и смену роли вместо двух похожих блоков.
- Убраны N+1: участники страницы партий и ники пригласивших берутся одним
запросом вместо запроса на строку (20 партий = 20 лишних запросов с двумя
join каждый).
- Счётчики партий считает СУБД (COUNT/MAX) вместо выгрузки всех строк ради
len() и max() в Python.
#8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
Прогон /code-review по тестам показал, что проверка обхода каталога ачивок
ничего не проверяла: httpx нормализует «..» в URL до отправки, запрос уходил
на /api/admin/ и до обработчика не доходил — тест был бы зелёным и без
защиты. Теперь percent-кодированная форма плюс проверка конверта ошибки,
чтобы промах роутинга не выдавал себя за отказ.
Добавлено недостающее: передача владения группой (обратная сторона защиты
последнего владельца), сдвиг версии партии при загрузке вложения, совпадение
кэш-бастера аватара между профилем и лидербордом. Проверка выживания группы
после отказа в удалении теперь смотрит на саму группу и её партии, а не
только на код ответа.
#8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
Находки прохода /code-review high по frontend/src:
- CreateMatchPage: барабан рандома, таймеры и строки адресовались по индексу,
а удаление строки индексы сдвигает — фракция могла записаться соседу или
потеряться. Строки получили стабильный id.
- AdminAccountsPage: переключение «активен» глотало ошибку и всё равно
показывало «Сохранено».
- useServerEvents/useFinishMatch: завершение чужой партии не инвалидировало
историю игр и публичные профили — открытый профиль показывал состав до
завершения.
- guards/useMe: обрыв связи не отличался от «нет сессии», и мигание сети
выкидывало авторизованного пользователя на /login. Транспортные ошибки
повторяем, гварды показывают сообщение вместо редиректа.
- format: fallback на нераспознанную дату не работал (new Date не бросает
исключение), и в интерфейс попадало «NaN.NaN NaN:NaN».
- PlaceEditor: без onPointerCancel прерванный перенос оставлял блок с классом
dragging и сдвигом, которые React не снимает — они выставлены в обход него.
- AdminFactionsPage: refetch после сохранения одной фракции затирал
несохранённый ввод в остальных.
- MatchDetailPage: nav(-1) после удаления уводил из приложения при открытии
партии по прямой ссылке; id из URL мог быть NaN и уходил в запрос.
#8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
Четыре теста закрывают то, что чинил предыдущий коммит: обход каталога
ачивок через slug, неподвижная версия партии при правке участников,
удаление группы с партиями (409 вместо 500) и разжалование последнего
владельца.
#8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
Находки прохода /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
Блок озаглавлен «Незавершённые партии», а каждая карточка заканчивается
кнопкой «Завершить» — бейдж дублировал и то и другое; на странице группы,
где название группы не показывается, он вдобавок висел в строке один.
Заодно снято мёртвое ветвление в MatchListView: незавершённые партии туда
не попадают ни из группы (там отдельный блок), ни из истории профиля
(бэкенд фильтрует по status="finished"). На странице самой партии бейдж
остаётся — там он единственный указатель статуса.
#26
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
В «Аккаунте» появился блок «История игр» с двумя тумблерами — «Только
лучшая партия» и «Подробные карточки»; выбор уходит в профиль, поэтому
переживает перезаход и другое устройство.
Новый компонент MatchHistory: подробный режим переиспользует список партий
группы (MatchListView со всеми участниками), компактный рисует строку с
результатом самого игрока — место, фракция, дата и длительность.
Публичный профиль показывает ту же историю в режиме владельца и без
контролов: страница намеренно read-only.
schema.d.ts пересобран с живого бэкенда (npm run gen:api).
#1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Выборки партий по игроку в бэкенде не было — только по группе. Добавлен
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>
На главной партии приходят из разных групп, поэтому название нужно; на
странице самой группы оно повторяет заголовок страницы. Блок получил
проп showGroupName (по умолчанию true) — главная не меняется.
#2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk
В кастомизации профиля появился выбор любимой фракции из полного
справочника (PickerSelect, все 12 фракций): фракция — свойство игрока,
а не группы, поэтому список не зависит от подключённых дополнений.
Дополнение показано в строке опции приглушённым текстом, снятие выбора —
кнопкой «Убрать».
В карточке статистики «Любимая фракция» теперь берётся из профиля, а
«Основная фракция» переименована в «Чаще всего играет на» и печатает
название в предложном падеже. Публичный профиль правок не потребовал —
он использует ту же карточку и ту же схему.
schema.d.ts пересобран с живого бэкенда (npm run gen:api).
#17
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk
Раньше «любимая» вычислялась как самая частая среди партий с ручным
выбором фракции. Теперь это поле профиля: 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
На странице группы идущая партия лежала в общем списке «Партии» и
отличалась от завершённых только бейджем «идёт». Теперь она выносится
блоком InProgressMatches — тем же компонентом, что на главной.
MatchListItem уже содержит всё, что нужно HomeInProgressMatch (started_at,
player_count и тот же MatchListParticipant), поэтому бэкенд не тронут:
недостающие group_id и group_name берутся из useMe/useGroup. Из списка
«Партии» идущие партии исключены, иначе одна партия показывалась бы
на странице дважды.
#2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk
Пропорция полукруга сохранена (ширина = 2x высоты), над кромкой панели
купол снова выступает — на 6px при высоте подвала 64px. Прежние 125x62.5
после прижатия базы к нижнему краю утапливали купол в панель.
#4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk
В DuckDuckGo шапка держала отступ от адресной строки — тот же safe-area,
что раньше отодвигал подвал снизу. Insets браузер отдаёт именно из-за
viewport-fit=cover: он передаёт управление безопасной зоной странице.
После отказа от отступов сверху и снизу компенсировать нечего, поэтому
cover снят — вьюпорт снова размещает браузер, а env() везде даёт ноль.
Следом убран --safe-top и три его использования (.top-bar, .side-menu,
.toast-top-wrap): без cover переменная всегда нулевая. Токенов safe-area
в проекте больше нет.
#4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk
Подвал держал внутренний отступ в safe-area снизу, а купол FAB был
дополнительно поднят на 8px — у нижней кромки оставалась пустая полоса.
Отступ снят: высота панели равна --bottombar-height, база купола на
нулевой отметке. Токен --safe-bottom больше нигде не используется и
удалён, отступ сверху (--safe-top) остаётся у шапки.
#4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk
Подвал был position: fixed, шапка — position: sticky при html/body с
height: 100%. В мобильных браузерах со скрывающейся адресной строкой
layout viewport меняет высоту при прокрутке: fixed-подвал за ним
дёргается, а у sticky-шапки нет однозначного прокручиваемого предка.
Каркас переведён на 100dvh с единственной прокручиваемой областью
(.app-main) — шапка и подвал стали обычными элементами флекс-колонки и
дёргаться им больше нечем. Safe-area ограничена потолком 24px: при
viewport-fit=cover часть браузеров (DuckDuckGo, Firefox Focus) отдаёт
inset поверх уже смещённого вьюпорта, из-за чего шапка раздувалась вдвое,
а надпись и колокольчик съезжали вниз. Заодно шапка защищена от
распирания длинным названием на узких экранах.
#4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk
Под любимой (только ручной выбор) — основная: max по всем партиям,
включая рандомные раздачи. Есть у любого сыгравшего игрока.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Партии, где фракция выпала рандомом (was_random), в счётчик любимой
не идут: рандомная раздача о предпочтениях не говорит. Если игрок ни
разу не выбирал фракцию сам — любимой нет вовсе. Лучшая/худшая фракции
и остальная статистика считаются по всем партиям, как раньше.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Кнопка завершения теперь вне карточки — без отступа кнопка отмены
прилипала к ней. На завершённой партии отступ схлопывается с margin
карточки, вид не меняется.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Вместо select-ов мест — PlaceEditor: вертикальный список блоков игроков
(верхний — 1-е место), бросок на другого игрока сливает в ничью с общей
рамкой и местом (✂ выносит обратно), выбывшие — перетаскиванием в
пунктирную зону (сервер сам ставит им последнее место). Комментарий об
игроке — строка в каждом блоке. Причина победы — PickerSelect (дженерик
расширен на строковые id), общий комментарий и медиа — карточкой,
кнопка завершения отдельно. Места считаются по блокам и всегда дают
валидный competition ranking (1,2,2,4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Фон .participant-row: surface-2 → surface; surface-2 остаётся только у
интерактивных элементов внутри (поля, кнопки, выпадашки). Затрагивает
старт партии, форму завершения и админ-правку — везде единообразно.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Раньше уведомления удалялись через 72 часа после создания независимо от
прочтения: кто не зашёл за 3 дня, не видел их вовсе. Теперь по времени
чистятся только прочитанные (72ч после прочтения), а от роста у
незаходящих защищает кап MAX_PER_USER=100 — при создании удаляются
старейшие сверх лимита. Механика доставки не менялась: запись в БД +
pull при заходе, SSE лишь ускоряет обновление открытых вкладок.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Вся информация об очках раскрывается кнопкой-карточкой (шеврон вниз/вверх,
стиль карточек списка партий) — задел под будущие разделы справки.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PlayerSelect обобщён в PickerSelect (рендер опций задаёт родитель) — им
оформлены оба поля создания партии. Фракции, выбранные в других строках,
не предлагаются (сервер при рандоме и так их исключал); набор фракций
по-прежнему приходит из available_factions группы с учётом дополнений.
Выбранное значение ищется в полном списке, а не в отфильтрованном:
после «Рандом всем» с повторами фракция строки может быть занята соседней.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Из списков убраны варианты-плейсхолдеры («— игрок —» в выборе игрока,
«— фракция —» в выборе фракции — disabled+hidden, в закрытом виде текст
остаётся). Фон .suggest-list — surface-2, как у input/select; hover строк —
--border. Скорость: дебаунс поиска 250→100 мс, в инвайте показанный список
дофильтровывается по вводу на клиенте, не дожидаясь ответа сервера;
touch-action: manipulation на строках и триггере.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Вместо нативного select — кнопка-триггер (аватар + ник) с выпадающим
списком всех игроков группы в стилистике подсказок инвайта; игроки,
уже выбранные в других строках, не предлагаются (заодно исключены
дубли). Для аватаров в списке MemberRead теперь отдаёт avatar_url.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ProfileStats теперь отдаёт min_games (порог «Новичков» из API, без
дублирования константы на фронте): пока сыграно меньше min_games партий,
«Очки (рейтинг)» в профиле показываются серебристым, как в топах;
золотой — только подтверждённый рейтинг.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Класс .provisional (серебро) применяется и при score = null: строки
«Ещё не играли» в топах и прочерк «Очки (рейтинг)» в карточке профиля.
Золотым остаётся только подтверждённый рейтинг.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Проблема: средний балл не учитывал число игр — новичок с 10 удачными
партиями обходил ветерана с сотнями стабильных.
Теперь score = (C·m + сумма очков) / (C + игр) × 100 при C=10, m=0.5:
к реальным партиям «дописываются» 10 виртуальных со средним 0.5, рейтинг
стартует около 50 и с опытом сходится к чистому среднему (формула IMDB).
Применено в топах и профиле; разбивка по фракциям осталась на чистом
среднем (служебная метрика «лучшая/худшая», сглаживание давило бы её к 50).
MIN_GAMES 5 -> 10 («Новички» во всех топах — до 10 партий; подписи берут
порог из API). Справка обновлена, добавлен тест (62 passed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
При вводе в поле приглашения («Список игроков») под инпутом всплывают
до 3 подходящих игроков — аватар + ник, в стилистике строк топов.
Клик подставляет ник в поле (приглашение отправляется кнопкой/Enter),
уже состоящие в группе не предлагаются.
Детали: компонент PlayerSuggestions (выбор по mousedown — до blur
инпута), хук useUserSearch с дебаунсом 250 мс и keepPreviousData,
классы .suggest-* в духе .notif-panel/.lb-row. В поиске общего списка
подсказки не нужны — таблица уже фильтруется вживую при вводе.
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>
Карточка топа целиком ведёт в общую статистику — вложенные ссылки на
профили конфликтовали с этим переходом. У Leaderboard появился флаг
profileLinks: на главной выключен, в «Списке игроков» группы (карточка
никуда не ведёт) строки-ссылки остаются, как и в общей статистике.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Как и в списке партий группы: карточка целиком ведёт в партию, ссылки
на профили внутри мешали выбору. Элемент возвращён к <button>.
Плюс правка подписи кнопки создания группы: «+ Создать группу» ->
«Создать группу».
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- рейтинг в блоках «Новички» (топ игроков и общая статистика) выводится
серебряным (--silver, класс .lb-score.provisional), а не золотым;
- MIN_GAMES 3 -> 5 (scoring.py) — в ранжированный топ попадают с 5 игр;
подпись «Новички (меньше N игр)» берёт порог из API, справка обновлена;
- pytest: 57 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Новый хук useRenameGroup (PATCH /api/groups/{id} — эндпойнт уже был на
бэкенде) с инвалидацией всех мест, где видно имя группы (шапка группы,
переключатель групп, главная). В настройках — карточка «Название» с
полем и кнопкой «Сохранить» (по образцу смены ника в аккаунте).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Карточка «Статистика игроков» на странице группы переименована в
«Список игроков», под списком — поле приглашения по нику (с проверкой
максимума участников и отправкой по Enter). В настройках группы остался
только состав с удалением участников.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Карточка партии целиком ведёт на страницу партии — вложенные ссылки на
профили мешали выбору партии (легко промахнуться в профиль). Ссылки на
профили остаются на странице партии, в топах и списке участников.
Карточка возвращена к семантичному <button>.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Единое правило: название страницы показывает верхняя панель (или активная
вкладка админки), тело страницы его не повторяет. Убраны дубли на общей
статистике, в справке и в админ-разделах (аккаунты, фракции, журнал);
пояснительные абзацы сохранены. Заголовки-сущности (имя группы, дата
партии) остаются h2 в теле.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- .btn-block в global.css заменяет 9 повторов style={{width:100%}};
- .badge-live — единый бейдж «идёт» (3 копии inline-стиля);
- незавершённые партии на главной используют стандартный .card,
как список партий (была единственная карточка на самодельных стилях);
- в списке партий добавлен бейдж 🎲 случайной фракции — как на странице
партии (раньше в списке он терялся);
- переключатель групп: кнопка «+» подписана «+ Создать группу»
(по образцу «+ Добавить игрока»), убран пустой span.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- «Winrate» -> «Винрейт» в карточке статистики; в справке добавлена
расшифровка «Винрейт (WR)» — WR остаётся в компактных таблицах;
- кнопки сохранения: «ОК» -> «Сохранить» (аккаунт, фракции, аккаунты
в админке); отмена в модалках/формах — везде «Отмена», не «Назад»;
- пустые состояния приведены к шаблону «X пока нет.»;
- роли участников группы переведены: owner/member -> владелец/участник,
бейдж admin -> «админ»; «Имя обновлено» -> «Ник обновлён»;
- «N игр.» в значении числа игроков -> «N игроков» со склонением
(новый хелпер plural в domain/format.ts);
- «минимум 2 участника» -> «минимум 2 игрока» (в партии — игроки,
в группе — участники).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Новый formatDate в domain/format.ts для date-only строк (played_at,
last_match_at). Применён: список партий, заголовок страницы партии
(раньше там было «2026-07-13»), записи в админке, «последняя партия»
на странице группы.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- админка, записи: удаление партии раньше срабатывало сразу по клику на
иконку и показывало «Удалено» даже при ошибке — теперь модалка
подтверждения и честная обработка ошибок;
- ачивки и dev-удаление аккаунта: window.confirm заменён на ConfirmDialog
(в приложении принят именно он);
- настройки группы: удаление участника — с подтверждением.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Новый компонент Switch (разметка .switch из global.css). Применён:
- создание группы: дополнения теперь свитчи, как в настройках группы;
- настройки группы: базовые дополнения показываются, как при создании
(включены, заблокированы, подпись «всегда включено»), а не скрываются;
- админка: «Группировать по группам» и флаг «выбыл» в правке партии.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Новый компонент PlayerLink (ссылка на /u/:id, кликабельно всё поле строки,
stopPropagation для вложенности в кликабельные карточки). Применён:
- Leaderboard и общая статистика (рефакторинг ручных Link);
- страница партии: результаты и состав;
- настройки группы: список участников;
- список партий и незавершённые на главной (карточки button -> div,
т.к. ссылки внутри button — невалидная разметка).
«пригласил: X» в приглашениях остаётся текстом — в API нет id пригласившего.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- deploy/pi/README: пошагово от чистого Pi (Docker → SSH-ключ туннеля на самом Pi → 2 файла → up),
с объяснением назначения ключа и кодирования в base64 (TUNNEL_KEY_B64).
- .env.example/README: PowerShell-команда base64 через Resolve-Path (абсолютный путь — иначе
[IO.File]::ReadAllBytes искал относительно домашнего каталога .NET и падал).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Теперь на Pi нужны только docker-compose.yml и .env — без репозитория, сборки и файла ключа.
- docker-compose.yml: pull_policy: always (образы тянутся из реестра, сборка не запускается);
ключ туннеля больше не монтируется томом — берётся из .env (TUNNEL_KEY_B64, base64);
у VPS_TUNNEL_HOST убран :? (ломал бы buildx bake на ПК, где переменная не задана) — проверка
обязательности перенесена в tunnel.sh (рантайм).
- deploy/tunnel/tunnel.sh: приватный ключ из TUNNEL_KEY_B64 (base64) с фолбэком на смонтированный
/key/id_tunnel (для dev/test, где репозиторий на хосте).
- .env.example: TUNNEL_KEY_B64 + как сгенерировать base64 (Git Bash / PowerShell).
- deploy/pi/README: полный поток «голый Pi → 2 файла»: Docker, curl compose+.env из Gitea, up;
обновление, автозапуск, бэкап как опция.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Заглушка вместо Pi, пока он не готов: поднимает forbiddenstars.ru с ПК через слот VPS 9000,
APP_ENV=production, локальная x86-сборка (без реестра), отдельный проект forbidden-stars-temp
со своими постоянными томами под прод-данные. Позже данные мигрируют на Pi (backup → restore).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- scripts/build-push.ps1: arm64 buildx bake + push, аналог build-push.sh для Windows-дева (ASCII).
- deploy/pi/README: указать .ps1 для Windows и почему `bash ...` в PowerShell не работает.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- docker-compose.yml: image у app и tunnel → путь реестра ${IMAGE_REGISTRY}/...:${IMAGE_TAG}
(build: оставлен фолбэком для сборки на Pi). Тома/настройки прежние.
- scripts/build-push.sh: buildx bake под linux/arm64 + push обоих образов в реестр (запуск на ПК).
- .env.example: блок ОБРАЗЫ ПРОДА (IMAGE_REGISTRY=gitea.arseniev.info/notbigghost, IMAGE_TAG=latest).
- deploy/pi/README: запуск/обновление через docker compose pull; альтернатива — сборка на Pi;
заметка про HTTPS-реестр / insecure-registries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- docker-compose.yml: слот туннеля параметризован (${VPS_TUNNEL_PORT:-9000}), VPS_TUNNEL_HOST
стал обязательным (${...:?}), лимиты ресурсов из .env (APP_MEM_LIMIT/APP_CPUS),
stop_grace_period 30s (чек-пойнт WAL SQLite), security_opt no-new-privileges на обоих сервисах;
healthcheck/логирование/тома сохранены (имена томов не менялись — данные не осиротеют).
- .env.example: VPS_TUNNEL_PORT по умолчанию НЕ задаётся (каждый контур берёт свой слот:
dev→9001, prod→9000, test→9001); добавлены ADMIN_NICKNAME, DEV/PROD_ACHIEVEMENTS_DIR,
APP_MEM_LIMIT/APP_CPUS.
- deploy/pi/README: чек-лист .env для Pi; запуск одним `docker compose up -d --build`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Новая страница /help: формула League Points, нормировка по числу игроков, ничьи,
выбывшие = последнее место, порог 3 партии для топа, прочие показатели. Текст
соответствует services/scoring.py. Задел под будущие правила и подсказки.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>