Wires the first end-to-end command through the full pipeline:
inspector rename action → local order draft → user.games.order
submit → optimistic overlay on map / inspector → server hydration
on cache miss via the new user.games.order.get message type.
Backend: GET /api/v1/user/games/{id}/orders forwards to engine
GET /api/v1/order. Gateway parses the engine PUT response into the
extended UserGamesOrderResponse FBS envelope and adds
executeUserGamesOrderGet for the read-back path. Frontend ports
ValidateTypeName to TS, lands the inline rename editor + Submit
button, and exposes a renderedReport context so consumers see the
overlay-applied snapshot.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
68 KiB
Функциональная спецификация Galaxy
Документ описывает, что делает платформа Galaxy в терминах пользовательских
операций и логики каждого сервиса, которая их реализует. Каждый раздел
проводит читателя по одному доменному сценарию: кто инициирует операцию,
что gateway проверяет и форвардит, что backend валидирует и сохраняет,
что возвращается клиенту, и какие побочные эффекты при этом запускаются
(почта, push, операции с контейнерами).
Это отправная точка для любого изменения, затрагивающего поведение системы. Точные форматы протоколов, словарь кодов ошибок, переменные окружения, значения по умолчанию, лимиты троттлинга, имена таблиц и колонок, field-level-валидация — всё это лежит в нижнеуровневых источниках:
ARCHITECTURE.md— глобальная архитектура, модель безопасности, транспортный контракт.galaxy/<service>/README.md— структура сервиса, конфигурация, эксплуатация.galaxy/<service>/openapi.yaml,*.proto— wire-контракты.galaxy/<service>/docs/flows.md— sequence-диаграммы.
Этот файл сознательно опускает такие детали. Если этот файл расходится
с нижнеуровневым источником, см. правило синхронизации в проектном
CLAUDE.md.
Внимание. Этот файл — перевод английского
FUNCTIONAL.mdи не является источником истины. Авторитетна английская версия; при расхождении выигрывает она. Каждое точечное изменение вFUNCTIONAL.mdдолжно быть зеркально внесено сюда в том же патче (переводить только затронутые абзацы). Полный перевод заново выполняется только по явному запросу владельца проекта.
Документ организован по доменным сценариям, не по группам HTTP-маршрутов. Публичные, user-аутентифицированные и admin-операции могут оказаться в одном разделе, если все они участвуют в одном бизнес-флоу.
Содержание
- Аутентификация и устройство-сессия
- Управление аккаунтом
- Жизненный цикл игры в лобби
- Участие в лобби
- Реестр названий рас
- Игровая сессия
- Канал push
- Уведомления и почта
- Гео-сигнал
- Администрирование
1. Аутентификация и устройство-сессия
Раздел описывает, как анонимный клиент становится аутентифицированным и остаётся таковым, пока серверное действие не отзовёт эти полномочия.
1.1 Состав
В составе: выпуск e-mail-вызова на вход, его подтверждение (с созданием аккаунта при первом входе и регистрацией публичного ключа клиента), создание устройства-сессии, поиск сессии для каждого аутентифицированного запроса, отзыв сессии со стороны сервера.
Вне состава: формат конверта и схема подписи, которые используются каждым аутентифицированным запросом — определены однажды в ARCHITECTURE.md §15 и переиспользуются всеми последующими разделами; хранение ключа на стороне клиента; маршрутизация push-событий внутри gateway к конкретному стриму подписчика.
1.2 Выпуск вызова на вход
Клиент отправляет e-mail на публичный auth-маршрут gateway. Маршрут не аутентифицирован — ещё нет устройства-сессии, к которому можно было бы привязаться.
Gateway относится к этому маршруту как к более строгому классу "public auth": применяет per-IP и per-identity (per-email) anti-abuse, ограничение на размер тела, allow-list HTTP-методов, после чего форвардит запрос в backend. Сбои upstream-адаптера проецируются обратно клиенту с тем же статусом и envelope-ошибкой; транспортные сбои — обобщённым ответом "недоступно".
Backend выпускает непрозрачный идентификатор вызова и отправляет письмо подтверждения через durable mail outbox. Форма ответа идентична независимо от того, принадлежит ли e-mail существующему аккаунту, новому или попадает под троттлинг — endpoint нельзя использовать для перечисления аккаунтов.
Ветки внутри backend:
- Permanent block. Если адрес заблокирован на уровне аккаунта, запрос отклоняется. Это единственная account-state-ветка, которая отдаёт отдельный код ошибки; все прочие ветки возвращают стандартную форму с challenge-id.
- Throttle. Если для одного e-mail в окне троттлинга уже существует слишком много непогашенных и не истёкших вызовов, backend переиспользует последний имеющийся вызов вместо создания нового. Клиент получает ту же форму ответа и не знает о повторе.
- Иначе. Backend создаёт новый вызов с разрешённым preferred_language
(выводится из опционального поля
localeв JSON-теле — оно имеет приоритет — либо, если оно отсутствует или пустое, из заголовкаAccept-Language, форварднутого gateway, с откатом на дефолт) и в той же транзакции ставит auth-mail-строку прямо в outbox. SMTP-доставка асинхронна; auth-ответ возвращается, как только строки challenge и outbox durably закоммитены. Поле в теле — это канонический канал, потому что Safari молча сбрасывает выставляемые из JS заголовкиAccept-Language; клиентам не на Safari достаточно одного заголовка.
1.3 Подтверждение вызова
Клиент отправляет challenge id, код из письма, свежий публичный ключ
Ed25519 и выбранную IANA-таймзону. Gateway применяет тот же
public-auth anti-abuse-класс, но per-identity-бакет ключуется по
challenge id, а не по e-mail. Accept-Language на этом endpoint
не учитывается — preferred_language был зафиксирован на этапе send
и проигрывается из строки challenge.
Backend валидирует challenge под row lock: отклоняет неизвестные, истёкшие или уже погашенные id, инкрементирует счётчик попыток и сжигает challenge при достижении потолка. После того как код сошёлся, backend перепроверяет permanent-block-флаг — это ловит случай, когда админ применил блок между send и confirm — и отклоняет запрос, если флаг выставлен. На успешном пути backend гарантирует существование аккаунта (синтезирует неизменяемый display-handle только при первом входе и заполняет declared_country по source IP), после чего помечает challenge consumed и в той же транзакции создаёт устройство-сессию, привязанную к публичному ключу вызывающего. Ответ несёт идентификатор новой устройства-сессии.
Challenge — single-use. Повторное подтверждение того же id возвращает
ту же непрозрачную форму invalid_request, что и подтверждение
неизвестного или истёкшего id; API сознательно не различает эти
три случая, чтобы атакующий не мог майнить состояние challenge.
Throttle-переиспользование на стороне send означает, что клиент,
попавший под троттлинг, получит обратно последний существующий
challenge_id вместо свежего, но каждый id всё равно гасится ровно
один раз.
1.4 Поиск сессии для каждого запроса
Когда у клиента есть идентификатор устройства-сессии и приватный ключ, каждый аутентифицированный вызов — это подписанный запрос к gateway по аутентифицированному edge-листенеру (Connect / gRPC / gRPC-Web на одном HTTP/h2c-порту). Gateway — единственный компонент, который видит подпись запроса; backend доверяет вердикту gateway.
Gateway нужен публичный ключ сессии для проверки подписи, поэтому каждый аутентифицированный запрос разрешает устройство-сессию через in-memory LRU-кэш (с ограничением на число записей плюс TTL-страховка). При промахе кэш зовёт endpoint backend для поиска сессии и заполняет запись. Gateway отклоняет запрос, если кэш сообщает "сессия неизвестна" или "отозвана"; иначе он проверяет конверт согласно ARCHITECTURE.md §15 и форвардит проверенный payload в backend по обычному REST, инжектируя в заголовке резолвлёный user_id. Backend никогда не выводит identity из тела запроса.
Backend обновляет last_seen_at в строке сессии при каждом успешном
поиске — это даёт админам видимость того, когда каждая закэшированная
сессия в последний раз резолвилась на edge. Обновление — часть
транзакции поиска; сбои логируются, но не пропагируются вызывающему.
Кэш инвалидируется через push-канал, а не через периодический
рефреш: событие session_invalidation переключает статус закэшированной
записи на revoked, после чего последующие запросы, привязанные к
этой сессии, отклоняются без повторного похода в backend. TTL — это
страховка на случай потерянных событий (курсор устарел, gateway
перезапустился) — в установившемся режиме push-события являются
авторитетным источником инвалидации.
1.5 Отзыв
Отзыв делает устройство-сессию неспособной аутентифицировать любой будущий запрос и принуждает закрыться все push-стримы, привязанные к ней. Триггеры разделяются на две группы.
Инициированный пользователем (logout). User-surface предоставляет
три операции: получить свои активные сессии, отозвать одну и отозвать
все. Gateway форвардит их в backend как обычные аутентифицированные
запросы. Backend проверяет, что целевая сессия принадлежит
вызывающему (иначе возвращает ту же форму, что и отсутствующая
сессия — чужие session id не могут быть зондированы), атомарно
переключает device_sessions.status на revoked и вставляет строку
в session_revocations, после чего публикует одно
session_invalidation-событие на каждую отозванную сессию.
Инициированный админом и lifecycle. Санкции, подразумевающие
отзыв сессий (сейчас — permanent_block), admin-инициированный
soft-delete и пользовательский self-soft-delete — все они приводят
к in-process-вызову внутри backend. Действуют те же атомарные
UPDATE + audit-insert + push-эмиссия; audit-строка несёт другой
actor_kind
(admin_sanction / soft_delete_admin / soft_delete_user).
Когда backend опубликовал push-событие, gateway переключает закэшированную запись сессии на revoked и закрывает все активные push-стримы, привязанные к ней. Per-request internal-поиск против backend остаётся durable-страховкой: если push-событие потеряно, следующий поиск (после истечения TTL кэша) вернёт уже отозванную запись.
session_revocations — это аудит-журнал. Каждая строка несёт
revocation_id, device_session_id, user_id, actor_kind, пару
полей актора (actor_user_id для user-driven kind'ов,
actor_username для admin-driven kind'ов — ровно одно из двух
заполнено в каждой строке), reason и revoked_at. Операторы могут
запрашивать её, чтобы ответить "кто и почему отозвал эту сессию";
таблица append-only.
Endpoint /api/v1/internal/sessions/{id} в backend — read-only:
он несёт per-request session lookup, который gateway использует
для проверки подписанных конвертов. Internal revoke-endpoints
больше не существуют; revoke инициируется либо пользователем
(через user-surface), либо админом (через in-process-вызов внутри
backend).
1.6 Перекрёстные ссылки
- Wire-конверт, подпись, окно свежести, anti-replay: ARCHITECTURE.md §15.
- Зоны ответственности backend-модулей
auth,user,geo,mail,push: ARCHITECTURE.md §4 иbackend/README.md. - Семантика mail outbox для шаблона auth login-code: ARCHITECTURE.md §11.
- Фрейминг push-канала и правила переподключения: ARCHITECTURE.md §8. Пользовательская семантика push — в Разделе 7 этого документа.
2. Управление аккаунтом
Раздел описывает, что аутентифицированный пользователь может читать или менять в своём аккаунте и как удалить аккаунт.
2.1 Состав
В составе: чтение агрегата аккаунта, обновление мутабельного слайса профиля, обновление настроек (preferred_language, time_zone, declared_country), пользовательский soft-delete.
Вне состава: admin-side-мутации того же аккаунта (санкции, лимиты, изменения entitlement, admin-soft-delete) — описаны в Разделе 10. Переключение permanent_block — только для админов.
2.2 Агрегат аккаунта
Backend предоставляет один read-endpoint, который возвращает агрегат аккаунта вызывающего: durable-идентифицирующие поля (неизменяемый display-handle, e-mail), мутабельные слайсы profile и settings, текущий снимок entitlement, любые активные санкции и per-user-overrides лимитов. Агрегат — авторитетный клиентский взгляд "что платформа обо мне знает".
Display-handle синтезируется при первом входе (Раздел 1.3) и никогда не перезаписывается ни при последующих входах, ни при апдейтах профиля. Клиенты должны относиться к нему как к стабильному идентификатору, а не как к display-предпочтению.
2.3 Обновление профиля и настроек
Два различных мутирующих endpoint'а разделяют user-управляемые поля по природе изменения. Оба следуют PATCH-семантике — отсутствующие поля не трогаются, присутствующие заменяют сохранённое значение — и оба возвращают обновлённый агрегат.
Profile несёт одно display-ориентированное поле: display_name.
Явно пустое значение очищает сохранённое имя; пропуск поля
оставляет его нетронутым.
Settings несёт locale- и timezone-предпочтения:
preferred_language (BCP 47-тег) и time_zone (IANA-идентификатор).
Оба должны быть непустыми после trim, если они присутствуют;
timezone валидируется по IANA-базе перед коммитом.
declared_country не входит ни в один из patch'ей. Backend
пишет его один раз при регистрации из source IP
(Раздел 9) и считает неизменяемым после; нет
user-видимого пути его изменить.
2.4 Удаление аккаунта пользователем
Пользователь может попросить backend soft-delete'нуть свой аккаунт. Backend помечает строку аккаунта удалённой и запускает in-process-каскад, описанный в ARCHITECTURE.md §7. Конкретно:
- Каждая устройство-сессия пользователя отзывается
(Раздел 1.5) — одна audit-строка на сессию и одно
session_invalidation-push-событие на сессию. - Активные membership'ы переходят в
removed(admin-инициированный блок переключает их вblocked); pending-заявки переходят вrejected; входящие приглашения — вdeclined; исходящие приглашения — вrevoked. - Race-name-записи, принадлежащие пользователю — registered, reservation или pending_registration — удаляются одной cascade- записью.
- Owned-игры в не-running-статусах (
draft,enrollment_open,ready_to_start,start_failed,paused) отменяются. Owned-игры уже вrunningкаскадом не отменяются — engine-контейнер продолжает выпускать ходы, пока не завершится естественно; только membership-cleanup отвязывает пользователя. - Один
lobby.membership.removed-веер уведомлений уходит пользователю сreason=removed(илиreason=blockedдля admin-block-пути).
Endpoint не возвращает тела. Каскад best-effort внутри одного процесса: если downstream-модуль падает, ошибка логируется, но аккаунт остаётся помеченным удалённым.
2.5 Перекрёстные ссылки
- Admin-аналоги (sanction, limit, entitlement, soft delete): Раздел 10.
- Контракт каскада "user blocked / user deleted": ARCHITECTURE.md §7.
- Виды уведомлений, эмитящихся каскадом:
backend/README.md§10.
3. Жизненный цикл игры в лобби
Раздел описывает жизнь одной игры от создания до терминального состояния. Раздел 4 описывает, как игроки присоединяются к существующей игре; этот раздел сосредоточен на самой игре.
3.1 Состав
В составе: создание игры (private или public), обновление её
мутабельной конфигурации, переходы по машине состояний лобби,
отмена, повтор failed-старта, терминальные переходы (finished,
cancelled).
Вне состава: заявки, приглашения, membership'ы (Раздел 4), Race Name Directory-промоушен при завершении (Раздел 5), engine-команды во время running-фазы (Раздел 6).
3.2 Машина состояний
Машина состояний лобби — закрытый граф, описанный в ARCHITECTURE.md §7:
draft → enrollment_open → ready_to_start → starting → running ↔ paused → finished
↳ start_failed → ready_to_start (retry)
cancelled достижим из любого pre-finished-состояния.
Два базовых правила:
- Тип владения определяет surface. Privatе-игры несут
owner_user_id; переходы инициирует владелец через user-surface. Публичные игры — collective ownership админов (owner_user_id IS NULL); их переходы и изменения конфигурации идут через admin-surface. - Runtime-callback владеет одним переходом.
starting → runningиstarting → start_failed— единственные переходы, которые производит runtime-модуль, после того как engine-контейнер полностью поднялся или подтвердил сбой. Каждый прочий переход — user- или admin-действие.
3.3 Создание
Пользователь создаёт private-игру через user-surface. Backend
записывает новую игру с owner_user_id, равным вызывающему,
visibility private, в состоянии draft, с конфигурацией из
тела запроса в качестве начальных значений.
Public-игры создаются исключительно через admin-surface (Раздел 10). User-surface никогда не производит public-игру; асимметрия enforced в backend, не на уровне маршрута.
3.4 Прямые переходы
Владельцы инициируют прямые переходы через специальные endpoint'ы
(open-enrollment, ready-to-start, start, pause, resume,
retry-start). Каждый endpoint:
- проверяет владение игрой (или admin-scope для public-игр);
- проверяет, что исходное состояние совпадает с предусловием перехода, отклоняя с conflict иначе;
- обновляет lobby-запись и публикует все user-видимые уведомления, привязанные к переходу.
start ставит в очередь runtime-job (длинный pull / start /
init контейнера) и сразу возвращает "queued". Финальное движение
состояния (starting → running или starting → start_failed)
приходит позже через runtime-callback. retry-start возвращает
start_failed-игру в ready_to_start и позволяет владельцу
снова дёрнуть start.
pause и resume переключают между running и paused. Запущенный
engine-контейнер не сносится при pause; меняются только lobby-расписание
и флаги приёма команд.
ready-to-start всегда — explicit-действие владельца (или админа),
никогда не auto-fired. Переход проверяет, что число одобренных
участников не меньше min_players, и иначе отклоняет с conflict.
3.5 Отмена и завершение
cancel достижим из любого pre-finished-состояния. Владельцы
могут отменять свои игры; админы — любые. Отмена примиряет
оставшиеся заявки, приглашения и membership'ы; она не повышает
race-name-резервации.
finished производится внутри backend, после того как engine
сообщает о завершении игры. Переход сносит engine-контейнер,
замораживает lobby-запись и триггерит Race Name Directory-промоушен
для capable-finishes (Раздел 5). Оба
терминальных состояния поглощающие.
3.6 Админские оверрайды
Администраторы могут делать force-start, force-stop и
ban-member на любой игре (public или private), независимо от
состояния. force-stop переводит игру в stopped-состояние и
сносит engine-контейнер; ban-member удаляет membership и
запрещает пользователю снова присоединиться
(Раздел 4).
3.7 Перекрёстные ссылки
- Словарь машины состояний и правила переходов: ARCHITECTURE.md §7.
- Жизненный цикл runtime-job (асинхронная работа за
start): ARCHITECTURE.md §13 иbackend/docs/flows.md. - Public-vs-private-инварианты и поддерживающий их частичный индекс: ARCHITECTURE.md §4.
4. Участие в лобби
Раздел описывает всё, что связано с присоединением и выходом из существующей игры: заявки (для public), приглашения (для private) и membership'ы (после успешного присоединения).
4.1 Состав
В составе: подача заявки в public-игру, одобрение / отклонение заявки владельцем или админом, выпуск и активация приглашений, отказ получателя и отзыв выпустившим, листинг membership'ов для игры, удаление или блокировка участника.
Вне состава: сама машина состояний игры (Раздел 3) и in-game-команды, когда участник уже играет (Раздел 6).
4.2 Заявки (public-игры)
Пользователь подаёт заявку в игру по id. Заявки принимаются
только в public-играх; попытка против private-игры отклоняется
с conflict. Игра должна дополнительно быть в enrollment_open
(единственное enrolment-принимающее состояние для заявок).
Backend также отклоняет запрос, если пользователь уже member или
в block-листе игры (через ban-member). Иначе сохраняет заявку
как pending и эмитит уведомление в admin-канал.
Владелец — или администратор для public-игр — одобряет или отклоняет заявку через специальные endpoint'ы. Одобрение создаёт membership для подающего и эмитит соответствующее уведомление. Отклонение просто записывает терминальное состояние; membership не появляется.
4.3 Приглашения (private-игры)
Приглашения принимаются только в private-играх; попытка
выпустить для public-игры отклоняется с conflict. Владелец
выпускает приглашение, пока игра в draft, enrollment_open
или ready_to_start.
Сосуществуют две разновидности:
- User-bound — установлен
invited_user_id; погасить может только этот пользователь. Эмитится уведомлениеlobby.invite.receivedполучателю. - Code-based —
invited_user_idпуст; backend минтит hex-код при выпуске, и любой вызывающий, знающий код, может погасить. При выпуске уведомление не эмитится (получатель ещё не привязан).
Каждое приглашение несёт срок действия (по умолчанию из
конфигурации, если тело пропускает expires_at). Получатель
гасит (создаёт membership) или отклоняет; выпустивший может
отозвать выданное приглашение в любое время до погашения.
4.4 Membership'ы
Membership'ы перечисляют игроков, прикреплённых к игре. Владельцы могут удалить или заблокировать члена; член может также удалить сам себя. Удаление чисто прекращает участие; блок дополнительно запрещает тому же пользователю снова подаваться или гасить будущее приглашение для той же игры.
Admin-surface предоставляет ban-member как cross-game-policy-
аналог owner-блока.
4.5 Личные списки
User-surface предоставляет три "my"-листинга (games, applications, invites). Они проецируют участие вызывающего по всем играм без необходимости заранее знать game-id'ы — это даёт возможность для dashboard- и inbox-вью.
4.6 Уведомления
Каждое изменение состояния в этом разделе эмитит уведомление из
каталога: lobby.invite.received, lobby.invite.revoked,
lobby.application.submitted, lobby.application.approved,
lobby.application.rejected, lobby.membership.removed,
lobby.membership.blocked. Раздел 8
описывает веер.
4.7 Перекрёстные ссылки
- Жизненный цикл игры: Раздел 3.
- Каталог уведомлений и веер: Раздел 8
и
backend/README.md§10.
5. Реестр названий рас
Раздел описывает, как игрок выбирает имя своей in-game-расы и в итоге получает это имя зарегистрированным платформенно.
5.1 Состав
В составе: трёхуровневый реестр (registered, reservation, pending_registration), промоушен через "capable finish", пользовательский промоушен pending_registration в registered, sweeper-релиз по истечению TTL, уникальность через canonical-key- модель.
Вне состава: как движок реально потребляет выбранное имя — это живёт в Разделе 6.
5.2 Три уровня
- Registered — platform-unique. У одного canonical-key — не более одного живого binding к одному пользователю.
- Reservation — per-game. Один и тот же canonical-key может быть зарезервирован одним и тем же пользователем в нескольких активных играх одновременно, но два разных пользователя не могут зарезервировать один canonical-key в одной игре.
- Pending registration — переходный уровень между reservation и registered. Выпускается автоматически после "capable finish" (игра завершилась с тем, что игрок вырастил начальные значения планет и популяции) и даёт пользователю окно времени, чтобы превратить reservation в постоянную registered-запись.
5.3 Канонизация
Каждое имя (введённое пользователем или зарегистрированное платформой) сворачивается в canonical key. Канонизация confusable- aware (latin-cyrillic-look-alikes, цифро-буквенные подмены) и применяется единообразно по реестру; уникальность enforced по canonical-key, не по отображаемому имени. Cross-tier-конфликты по одному и тому же canonical-key блокируются на write через per-canonical advisory lock.
5.4 Путь продвижения
Reservation появляется, когда игрок именует свою расу во время
игры. Когда игра capable-завершается, backend автоматически
конвертирует reservation в pending_registration с TTL. Пока
pending-запись жива, пользователь может вызвать registration-
endpoint, чтобы продвинуть запись в registered. Если TTL
истёк раньше, периодический sweeper освобождает запись;
canonical-key снова доступен.
Pending registration может claim'нуть только пользователь, который её заработал; backend отклоняет попытку другого пользователя, даже если canonical-key совпадает.
5.5 Уведомления
Реестр эмитит lobby.race_name.registered,
lobby.race_name.pending и lobby.race_name.expired
владеющему пользователю. Раздел 8
описывает веер.
5.6 Перекрёстные ссылки
- Библиотека канонизации и записи глоссария ("canonical key", "capable finish"): ARCHITECTURE.md §19.
- Триггер промоушена внутри lobby-модуля:
ARCHITECTURE.md §7
(
lobby.OnGameFinished) иbackend/docs/flows.md.
6. Игровая сессия
Раздел описывает, что делает активный игрок, пока идёт игра: посылает команды и приказы, читает отчёты по ходам.
6.1 Состав
В составе: подача команд, подача приказов, чтение отчёта, turn-cutoff-поведение, которое закрывает окно команд во время генерации.
Вне состава: как сам engine-контейнер запускается, планируется или останавливается — это runtime-вопросы, описанные в Разделе 3 (start / stop) и Разделе 10 (admin-runtime-оверрайды). Wire-формат команд, приказов и отчётов — собственный контракт движка, здесь не дублируется.
6.2 Роль backend: pass-through с авторизацией
Подписанный конвейер аутентифицированного edge для in-game-трафика
использует четыре message types на аутентифицированной поверхности —
user.games.command, user.games.order, user.games.order.get,
user.games.report — у каждого типизированный FlatBuffers-payload.
Gateway транскодирует FB-запрос в JSON-форму, которую ждёт backend,
форвардит её REST'ом в соответствующий
/api/v1/user/games/{game_id}/* endpoint, после чего транскодирует
JSON-ответ обратно в FB перед подписью.
user.games.order.get — read-back-компаньон для user.games.order:
клиент использует его, чтобы восстановить локальный черновик приказа
после потери кэша (свежая установка, очищенное хранилище, новое
устройство).
Для каждого in-game-endpoint user-surface работает как авторизующий pass-through к engine-контейнеру. Backend:
- проверяет, что вызывающий — активный member целевой игры и что игра в состоянии, принимающем операцию;
- ребиндит поле
actorв теле на race-name вызывающего из runtime-player-mapping (клиент не несёт доверенный actor); - резолвит engine-endpoint (запущенный контейнер для
game_id) и форвардит вызов; - возвращает payload-ответа движка клиенту без переинтерпретации.
Backend не парсит содержимое payload команд или приказов сверх того, что требует авторизация. Движок — источник истины о валидности и порядке in-game-решений. Gateway знает типизированную FB-форму только чтобы транскодировать wire-формат; per-command- семантика живёт в движке.
6.3 Окно хода
Запущенная игра постоянно чередуется между окном приёма команд
и фазой генерации. Переход running → generation_in_progress —
cutoff: любая команда или приказ, пришедшие после cutoff,
отклоняются backend до форварда, потому что движок больше не
принимает запись для закрывающегося хода. После окончания
генерации backend заново открывает окно для следующего хода.
force-next-turn (admin) планирует one-shot-доп-тик, который
сдвигает следующий запланированный ход на один cron-шаг.
6.4 Отчёты
Per-turn-отчёты — read-only-вью, забираемые из движка по запросу. Backend авторизует вызывающего и форвардит запрос; в этом пути нет ни кэширования, ни денормализации.
6.5 Побочные эффекты
Успешная генерация хода публикует runtime-snapshot в lobby-модуль,
который обновляет денормализованное вью (текущий ход, runtime-
status, per-player-stats). Engine-отчёт "game finished" гонит
переход running → finished
(Раздел 3.5) и триггерит Race Name
Directory-промоушен (Раздел 5).
game.*-виды уведомлений (game.started, game.turn.ready,
game.generation.failed, game.finished) зарезервированы в
документации, но не имеют поставщика в кодовой базе сегодня;
notification-каталог явно их опускает
(backend/internal/notification/catalog.go). Добавление поставщика
аддитивно: зарегистрировать вид в каталоге, заполнить
MailTemplateID, если нужен email-веер, и заставить нужный
доменный модуль вызвать notification.Submit.
6.6 Перекрёстные ссылки
- Backend ↔ engine wire-контракт (
pkg/model/{order,report,rest}): ARCHITECTURE.md §9. - Жизненный цикл контейнера, дисциплина меток, согласование:
ARCHITECTURE.md §13
и
backend/docs/flows.md.
7. Канал push
Раздел описывает, как платформа пушит real-time-события аутентифицированным клиентам (turn-ready-сигналы, изменения состояния лобби, инвалидации сессий).
7.1 Состав
В составе: server-streaming-подписка, которую клиент открывает к gateway (Connect / gRPC / gRPC-Web фреймы все маршрутизируются на одну точку), bootstrap-событие, фрейминг форварднутых событий, control-канал backend → gateway, который производит эти события.
Вне состава: каталог видов событий — см.
Раздел 8 для notification-стороны и
backend/README.md §10
для закрытого списка.
7.2 Подписка клиента
Аутентифицированный клиент открывает server-streaming-вызов
SubscribeEvents на gateway. Gateway проводит ту же envelope-
проверку, что и для unary-запросов
(Раздел 1.4), затем
регистрирует стрим в своём внутреннем хабе. Первый фрейм,
получаемый клиентом — это gateway-подписанное bootstrap-событие
с текущим серверным временем, чтобы клиент мог калибровать свои
локальные часы без отдельного запроса.
7.3 Управление backend → gateway
Backend хостит единственный gRPC-сервис Push.SubscribePush,
потребляемый gateway. На каждую client-id gateway одновременно
существует ровно одна логическая подписка; переподключение с тем
же id заменяет старую подписку. Каждый фрейм в стриме несёт
монотонный курсор и одну из двух форм payload:
- Client event. Типизированный payload, адресованный одному
пользователю (и опционально одной устройства-сессии). Поставщики
передают в
push.Serviceобъектpush.Event(Kind + Marshal), сервис сам вызывает Marshal и кладёт байты вpushv1.ClientEvent.Payload. Gateway форвардит байты внутри подписанного клиентского конверта без переинтерпретации. Поставщики прикрепляют correlation-id'ы, которые gateway пробрасывает as-is. Новые виды событий поставляются с FlatBuffers-реализацией Event; виды, ещё не мигрировавшие, используют fallbackpush.JSONEvent, чтобы pipeline продолжал отправлять их без задержек на миграцию. - Session invalidation. Говорит gateway сбросить активные стримы и отклонить in-flight-запросы для затронутых сессий — путь распространения отзыва, описанный в Разделе 1.5.
7.4 Надёжность и переподключение
Backend держит in-memory ring-буфер недавних событий. При переподключении gateway шлёт последний потреблённый курсор; backend возобновляет с следующего события, если курсор всё ещё внутри freshness-window-TTL, или начинает с головы, если курсор устарел. Per-connection-обратное давление — drop-oldest: медленное gateway-соединение теряет старые события первыми, при каждом дропе пишется log-строка, чтобы обе стороны могли скоррелировать дыру.
Push-канал — best-effort. Durable-запись "мы попытались сообщить
этому пользователю об этом событии" живёт в notifications /
notification_routes (Раздел 8);
потерянное push-событие не значит, что платформа забыла событие.
7.5 Поставщики
Backend-поставщики, эмитящие в push-канал, — это: notification- диспатчер (push-маршруты из каталога) и session-модуль (события отзыва). Никакой другой доменный модуль не эмитит client-события, кроме notification-диспатчера.
7.6 Перекрёстные ссылки
- Wire-конверт, используемый для push-фреймов: ARCHITECTURE.md §15.
- Семантика переподключения и ring-буфера:
ARCHITECTURE.md §8
и
backend/docs/flows.md"Push gRPC". - Notification-диспатчер: Раздел 8.
8. Уведомления и почта
Раздел описывает, как платформа сообщает пользователю о событии через push или e-mail (или оба).
8.1 Состав
В составе: подача notification-намерения, веер по push- и email- каналам, durable mail outbox, dead-letter-обработка, оператор- инициированный resend.
Вне состава: per-event-семантика — когда срабатывает каждый вид, описано в соответствующем feature-разделе (Раздел 4 для lobby-видов, Раздел 5 для race-name-видов, Раздел 6 для game-видов).
8.2 Notification-намерение и веер
Доменные поставщики (lobby, runtime, geo) подают типизированное намерение в notification-модуль вместо передачи сообщения в конкретный канал. Модуль затем:
- enforced'ит идемпотентность по виду намерения плюс идемпотентному ключу от поставщика;
- резолвит получателей;
- материализует один маршрут на получателя на канал, по политике каталога, специфичной для типа (только push, только email, оба, или admin email);
- эмитит push-маршруты в gRPC push-стрим, потребляемый gateway;
- вставляет email-маршруты прямо в mail outbox.
Малформ-намерения карантинизируются в выделенную таблицу и никогда не блокируют поставщика.
8.3 Каталог
Каталог — это закрытый набор видов. Каждый вид специфицирует свои каналы и payload-поля, потребляемые шаблонами и клиентами. Три категории записей заслуживают отдельного упоминания:
auth.login_code. Это единственный вид, обходящий notification-pipeline целиком. Auth пишет email-строку прямо в outbox, чтобы commit challenge был атомарен с mail-enqueue.runtime.*-виды. Они доставляются на сконфигурированный admin email. Если admin email не сконфигурирован, маршруты ложатся со статусомskippedи оператор-логом — запрос никогда не падает из-за отсутствия operator-конфига.- Зарезервированные виды без поставщика.
game.*иmail.dead_letteredперечислены в каталоге, но текущий код никаких из них не эмитит. Добавление поставщика чисто аддитивно.
8.4 Mail outbox
Email — это Postgres-backed durable outbox. Поставщики (notification-маршруты и auth login-code-путь) пишут delivery- строку плюс рендеренные payload-байты в одной транзакции. Worker-горутина дренит outbox: подбирает строки под row-lock, пытается SMTP-доставку, записывает попытку и либо помечает строку sent, либо планирует следующую попытку с exponential backoff и jitter.
Доставка, превысившая бюджет попыток, переходит в dead-letter- таблицу; сам dead-lettering эмитит admin-notification-намерение. На старте worker дренит всё, что в pending или retrying — нет отдельного recovery-флоу.
Операторы могут переслать non-sent доставку с admin-surface
(Раздел 10). Resend по sent-доставке
отклоняется, чтобы оператор случайно не пере-отправил почту,
которая уже ушла из relay.
8.5 Видимость для оператора
Admin-surface перечисляет deliveries, attempts на delivery, dead-letters, notifications, notification-dead-letters и malformed notification-намерения. Ничего из этих листингов недоступно с user-surface.
8.6 Перекрёстные ссылки
- Таблица notification-каталога (виды, каналы, payload):
backend/README.md§10. - Внутренности mail outbox (таблицы, лог попыток, worker pickup):
ARCHITECTURE.md §11 и
backend/docs/flows.md"Mail outbox". - Push-транспорт для client_event-маршрутов: Раздел 7.
9. Гео-сигнал
Раздел описывает, что backend записывает о source IP аутентифицированного запроса и что он сознательно с этим не делает.
9.1 Состав
В составе: one-shot declared_country при регистрации, fire-and-forget per-request country-counter, оператор-only read-endpoint.
Вне состава: любой автоматический flagging, обнаружение account- takeover, geo-fencing, enforcement санкций, история версий. Гео-сигнал — это пассивная запись, не enforcement-механизм.
9.2 Что backend записывает
При регистрации (Раздел 1.3) backend ищет source IP в GeoLite2-country-базе и сохраняет полученный ISO-код страны на аккаунте. Это значение пишется ровно один раз на аккаунт; последующие входы из другой страны не перезаписывают его.
При каждом аутентифицированном запросе через user-surface fire-and-forget-горутина выполняет тот же поиск против request IP и инкрементирует per-(user, country)-счётчик. Сам запрос никогда не блокируется этой работой; горутина запускается после возврата handler'а.
Оба пути fail-open: ошибка geoip-поиска логируется, но никогда не блокирует пользователя.
9.3 Что backend НЕ делает
- Никакой агрегации по пользователям.
- Никакого автоматического flagging при смене страны.
- Никаких уведомлений из geo-сигнала.
- Никакой истории версий
declared_country. - Никакой корреляции с санкциями, лимитами или entitlement.
9.4 Доступ оператора
Admin-surface предоставляет один read-endpoint, перечисляющий per-user-country-счётчики. Данные предназначены для ручного inspect'а во время оператор-triage; UI-флоу поверх этого нет.
9.5 Дисциплина source IP
Backend читает source IP из самого левого X-Forwarded-For-
entry, откатываясь на connection peer, если заголовок отсутствует.
Backend доверяет значению, потому что сетевой сегмент между
gateway и backend — это платформенный trust boundary, edge уже
саниТизировал его. Это сделано намеренно и переутверждено в
ARCHITECTURE.md §10 и
§16.
E-mail-адреса никогда не пишутся в логи как есть. Backend пишет process-scoped HMAC-truncated hash, чтобы операторы могли скоррелировать log-строки внутри одного process lifetime без сохранения PII.
9.6 Перекрёстные ссылки
- Обоснование trust-boundary: ARCHITECTURE.md §10, §15, §16.
- Контракт one-shot-write при регистрации vs per-request-counter:
backend/README.md§11.
10. Администрирование
Раздел описывает каждую admin-only-операцию. Многие из них упоминались в предыдущих разделах (admin-overrides для лобби, admin-soft-delete и санкции, mail- и notification-inspection); этот раздел — консолидированное вью.
10.1 Состав
В составе: admin-аутентификация, cross-domain-admin-операции, их побочные эффекты на остальную часть платформы.
Вне состава: end-user-флоу, разделяющие домен с admin-операцией — они в своём собственном разделе.
10.2 Аутентификация и bootstrap
Admin-surface использует HTTP Basic Auth против backend-owned admin-account-таблицы; пароли хешированы bcrypt'ом. На старте, если bootstrap-admin-username и пароль сконфигурированы и таблица ещё не содержит строки с этим username, backend вставляет её. Insert идемпотентен: последующие рестарты ничего не делают.
Failed Basic Auth-ответ запрашивает оператор-tooling за credentials стандартным способом; realm-строка фиксирована, чтобы password manager оператора мог матчить её через deployments.
После первого деплоя bootstrap-пароль должен быть ротирован через admin-surface.
10.3 Управление admin-аккаунтами
Существующие админы могут перечислять других админов, создавать новых, искать конкретного, отключать или включать обратно админа, и сбрасывать пароль. Отключённая admin-строка не может аутентифицироваться; строка сохраняется, чтобы сохранить audit-references, а не удаляется.
Reset-password принимает новый пароль в теле запроса. Backend
bcrypt-хеширует его, заменяет admin_accounts.password_hash и
возвращает обновлённую AdminAccount-форму — сам новый пароль
никогда не возвращается. "Delivered out-of-band" поэтому
означает: админ, инициирующий reset — тот, кто должен сообщить
новое значение target-админу через какой-то канал вне платформы
(защищённый мессенджер, голос и т.п.); платформа не email'ит
и не auto-доставляет.
10.4 Администрирование пользователей
Для любого user-аккаунта админ может:
- перечислять и инспектировать аккаунты;
- применить санкцию;
- применить per-user-limit-override, корректирующий конкретную квоту;
- обновить entitlement (план, paid-флаг, source, validity);
- soft-delete'нуть аккаунт (тот же in-process-каскад, что и Раздел 2.4).
Каталог санкций сознательно минимален в MVP: единственный
поддерживаемый sanction_code — это permanent_block. Применение
переключает accounts.permanent_block, отзывает все активные
сессии (Раздел 1.5) и запускает тот же lobby-каскад,
что и soft-delete, со membership-статусом blocked
(Раздел 2.4). OpenAPI-схема
кодирует это как закрытый enum, чтобы будущие добавления были
явным breaking-изменением. Soft-delete всегда отзывает сессии;
санкции отзывают только когда вид документирует этот побочный
эффект (сегодня — только permanent_block).
10.5 Администрирование игр
Админы создают public-игры, перечисляют и инспектируют любую игру, делают force-start или force-stop игре и баннят member'а. Force-stop сносит запущенный engine-контейнер для игры; ban-member добавляет пользователя в block-лист игры и удаляет любой активный membership (Раздел 4.4).
Public-game-владение коллективное: строка несёт owner_user_id IS NULL и любой админ может действовать с ней. User-surface никогда
не производит и не транзишнит public-игру.
10.6 Администрирование runtime
Админы инспектируют runtime-запись для игры, рестартят engine- контейнер, патчат его image на более новый semver-патч в той же major / minor-линии и форсят one-shot-доп-тик хода.
Patch сознательно ограничен patch-компонентом. Major- или minor- смена версии требует явного stop / start игры, не in-place-апгрейда. Регистрация версий движка и disable — рядом.
10.7 Реестр версий движка
Реестр версий движка — источник разрешённых engine-image'ов. Поставщики (start, restart, patch) никогда не выбирают image- references сами; они читают из реестра. Disable версии — forward- looking-решение: существующие запущенные контейнеры держат свой текущий image до stop / start, но disabled-версия больше не eligible для новых стартов или патчей.
10.8 Администрирование почты и уведомлений
Операторы могут перечислять и инспектировать mail-deliveries, attempts на delivery, dead-letters, notifications, notification- dead-letters и malformed notification-намерения. Они также могут переслать non-sent mail-delivery (Раздел 8.4).
Эти вью — единственный путь к видимости почты и уведомлений вне телеметрии.
10.9 Администрирование geo
Единственный geo admin-endpoint перечисляет per-user-country- счётчики (Раздел 9.4). Admin-write- доступа к geo-данным нет; declared_country устанавливается раз при регистрации и не меняется, счётчики заполняются runtime'ом, а операторы могут только читать.
10.10 Перекрёстные ссылки
- Контракт каскада soft-delete: ARCHITECTURE.md §7.
- Жизненный цикл контейнера и арбитраж версий: ARCHITECTURE.md §13.
- Mail outbox и notification-диспатчер: ARCHITECTURE.md §11, §12 и Раздел 8.