crates/api
Админка читает опубликованные цели проекта через GET /v1/admin/projects/{project_id}/level-goals: только номер/идентификатор, версия, цели и ограничения уровней. Ответ исключает закрытые поля прохождения. Для старых шаблонов и колеса mechanic=null, levels=[]. Поле legacy_goals содержит только текст целей старого runtime после слияния стандартной конфигурации с переопределениями проекта через effective_template_config. Пустое или частичное переопределение не стирает стандартную цель серии. Для новых игр и колеса legacy_goals=[].
GET /v1/admin/auth/sessions выдаёт только собственные активные сессии: id, created_at, expires_at, current. limit — 1–50 (по умолчанию 20), cursor/next_cursor — UUID последней строки страницы, сортировка по UUID. Это последовательное чтение актуального списка; для новых входов список обновляют с начала. DELETE /v1/admin/auth/sessions/{session_id} отзывает собственный активный вход; чужой/истёкший/уже отозванный UUID даёт 404. Отзыв и аудит атомарны, реестр проверяется на следующем запросе. Текущий вход очищает cookie и требует повторной авторизации. Оба метода требуют 2FA, а cookie-запрос DELETE — допустимый Origin. Токены/хэши/IP не выдаются.
Внешний POST /v1/admin/onboarding/{step}/complete принимает только embed_copied: копирование подтверждается после ответа буфера обмена. Остальные стадии сервер отмечает по фактическому действию; first_play учитывает также существующие GameRun собственных проектов, включая старые аккаунты. Прямой запрос не может вручную подделать серверную стадию.
Учётные записи и безопасность администратора
Одна учётная запись предназначена для одного человека. Передача пароля и общая учётная запись команды не поддерживаются: аудит связывает действие с account, а установить человека за общей записью невозможно. Приглашений и новой модели ролей этот этап не вводит.
Админка использует только HttpOnly-cookie; прежнее значение токена в localStorage удаляется при обновлении авторизации. Admin CORS допускает credentials только для настроенных origin. Cookie-запись требует CSRF Origin, даже если рядом передан другой Bearer. Ошибка limiter 2FA даёт503, а не разрешение.
TRUSTED_PROXIES принимает явные IP/CIDR, пустой список никому не доверяет; ошибочный синтаксис и /0 останавливают запуск. Штатный Swarm определяет сети реальных reverse proxy Traefik и admin nginx, без общего доверия10/8.
Сессии администратора проверяются по реестру admin_sessions в Postgres, включая /v1/admin/me и поток 2FA. Реестр хранит UUID и SHA256 токена, учётную запись, срок и отзыв; исходный токен в БД не сохраняется. Подписанный токен без активной записи требует нового входа.
POST /v1/admin/auth/password требует действующую сессию с 2FA и поля current_password/new_password. До PG/Argon2 действует лимит10 попыток за15минут на account; превышение429, ошибка хранилища503, без смены пароля или отзыва сессий. Новый пароль имеет8–1024байта. В одной транзакции меняется Argon2 hash и отзываются все сессии учётной записи, включая текущую. После успеха нужен новый вход. Login и смена пароля блокируют одну строку account: старый пароль не создаёт сессию после отзыва. Logout отзывает серверную запись, затем очищает cookie на обоих paths.
По умолчанию сессия живёт один час. ADMIN_SESSION_TTL_SECONDS задаёт 300–86400секунд, одинаково для подписанного срока, cookie и 2FA-cache. Секунда exp уже недействительна. Миграция059 не переносит старые cookies в реестр: после обновления пользователи входят заново.
Отрицательная корректировка ресурса списывает средства из существующего баланса атомарно; недостаточный баланс отклоняется. Повтор с тем же ключом идемпотентности не списывает средства ещё раз. При удалении игрока очищается также его идентификатор внутри idempotency_key сохранённого webhook payload, чтобы последующая доставка не раскрывала исходный идентификатор.
The HTTP layer and all backend services: routes, services, background jobs, auth, db. Located at backend/crates/api/.
Router
scripts/check-openapi-routes.py проверяет граф основного Router, включая условный demo, nest/merge и каждую пару method+path. Health API /v1/health и /v1/ready входят в схему. Только три статические страницы Swagger исключены по точным методам и путям с причинами в scripts/openapi-route-exclusions.json. Отдельный worker Router /health, /ready слушает внутренний порт worker, в публичный граф API не входит. Маршруты админки и тестового inbox проверяются. Неявные HEAD у GET и OPTIONS CORS не отдельные регистрации; явные методы проверяются. Неизвестный синтаксис маршрутизации завершает проверку ошибкой.
All routes are assembled in src/routes/mod.rs. Public + admin endpoints are merged into one Axum router. Highlights:
| Path | Handler | Purpose |
|---|---|---|
GET /v1/health, /v1/ready | health | Liveness / readiness |
GET /v1/status | status | Engine v2 + D6; game_run_latency_ms p50/p95/p99 по механике и действию |
GET /v1/demo/token | demo | Dev-only session token |
GET /v1/widget.js, /v1/widget/config, /v1/widget/exposure | widget | Loader + config + A/B gate |
GET/POST /v1/admin/two-factor/* | two_factor | TOTP enrollment, confirm, challenge, recovery |
GET /v1/play?mode=play|preview | widget::play_shell | Thin iframe shell; mode обязателен, токена в адресе нет |
GET /v1/templates/{id}/bundle.js | templates | Serve template bundle |
POST /v1/game-runs | game_runs::start_run | Начать, продолжить или повторить уровень; сервер выбирает игру и уровень; повтор создавшего партию start_id — до FraudGate; Match3 10 стартов/мин |
POST /v1/game-runs/{run_id}/commands | game_runs::run_command | Один смысловой ход по command_id + sequence |
GET /v1/game-runs/{run_id} | game_runs::get_run | Восстановить состояние партии |
GET /v1/game-runs/{run_id}/reward | game_runs::get_reward | Шесть статусов награды для игрока без причин |
GET /v1/lobby | lobby::get_lobby | Бюджет 8 KiB (WARN при превышении), до 8 SQL; прогресс не меняет, при превышении лимита сохраняет флаг |
GET /v1/games/{game_id}/entry.js | game_assets | Точка входа клиента игры, no-cache |
GET /v1/game-assets/{game_id}/{version}/{file} | game_assets | Выпуск shell/<id>: br/gzip по Accept-Encoding, кеш на год |
PATCH /v1/admin/projects/{id} | admin | play_mechanic: строка или null; entry_mode: lobby/wheel_only; lobby_wheel_enabled: boolean. Проверки доступности, режима и опубликованного колеса. Ошибки mechanic_unavailable с reason, mode_not_allowed, wheel_config_missing, validation_error |
PUT/DELETE /v1/admin/projects/{id}/game-assets/{slot}/background | admin_game_assets | Фоны лобби/механики: нормализация, сброс и рост ревизии |
GET /v1/project-game-assets/{project_id}/{asset_id} | admin_game_assets | Выдача загруженного фона |
POST /v1/events | events | Player event ingress |
POST /v1/server/events | server | Server-to-server ingress (API key) |
GET /v1/progress/state | progress | Read progression |
GET/PATCH /v1/admin/projects/{id}/fraud-flags[/{flag_id}], POST .../reward-claims/{id}/release|reject, GET/PUT .../players/{uid}/fraud, GET .../fraud-metrics | admin_fraud | Разбор фрода: очередь, разрешение флага, ручной статус игрока, сводка (Task 9) |
merge(admin*, economy, *_webhook, docs) | admin & misc | Dashboard + billing + docs |
Services (src/services/)
Настройки игры в админке (G1.9)
GET /v1/admin/templates возвращает engine, selectable, player_title и client_status (ready, disabled, not_built, unsupported_version). Карточки не предлагают скрытые шаблоны; уже выбранная выключенная игра остаётся видимой с её состоянием. Совместимость режима проверяет сервер.
GET /v1/admin/projects/{id} дополнительно возвращает play_settings: active_players_24h — число разных игроков текущей механики с активной, непросроченной партией и активностью за последние 24 часа; site_wheel_ready и daily_wheel_ready подтверждают наличие клиента и опубликованных настроек. Нет клиента или таблицы конфигурации — колесо недоступно, не успешная заглушка.
PATCH настроек увеличивает play_config_revision один раз при фактическом изменении механики (включая старый active_template_id), режима показа, колеса, акцента или game_mode; то же значение ревизию не меняет. Аудит metadata.play_settings и изменение проекта атомарны.
The business logic. Key modules:
play_ingress.rs—PlayIngressService: idempotency, adapter dispatch, score reconciliation, error mapping (fraud_blocked/validation_error).fraud_gate/— the FraudGate pipeline (see FraudGate).progression.rs,cold_storage.rs,sync.rs,reconcile.rs,engine_metrics.rs— hot/cold progression (see progress).job_heartbeat.rs(Task 13) — Postgres-backed job liveness (job_heartbeatstable):heartbeat_success/heartbeat_failure, called once per completed loop iteration by eachjobs/*_queue.rs, andjob_health(aggregated, no per-instance rows) feeding/v1/status'smetrics.jobsandplayflow-worker's own/ready. Replacesengine_metrics's oldsync_lag_seconds/snapshot_last_success/dlq_depth/webhook_retry_queue_depthstatics, which the API process never saw the worker (a separate Swarm service) write to.rewards.rs— campaign trigger evaluation and claims. The claim key is always built server-side (campaign + user + trigger), never from the client's ownIdempotency-Keyheader (kept only asclient_idempotency_keyfor audit); eligibility (per-user cap, campaign total, cooldown) is checked and the row inserted inside one transaction that locks the campaign, so concurrent claims for it are serialized rather than racing a stale count.fraud_gate::reward_gate::decide_rewardruns inside that same transaction (Task 7) and is the single place that decides whether the claim is delivered now, held, sent to review, or rejected outright — see FraudGate §"Шлюз ценности". Notification dispatch is its own function,dispatch_reward_notification, called only when a claim is actually eligible (immediately, or later fromjobs/reward_release_queue.rs) — never from claim creation itself when the gate held it.claim_reward_for_campaign(pool-based, begins its own transaction) andclaim_game_won_reward(Task 8, takes the caller's already-open transaction) share one eligibility-check-then-insert body,claim_reward_for_campaign_tx—list_active_campaignsis generic over the executor for the same reason.claim_game_won_rewardis the only place in the codebase that creates agame_won-triggered claim, called fromgame_runs::execute_command.webhook.rs,webhook_inbox.rs— signed delivery + test inbox.economy.rs(Task 10, REWARDS-01/REWARDS-02):player_resource_balancesin Postgres is the authoritative balance, not the Dragonfly hash it used to be read from and written to first.spendis one conditionalUPDATE ... WHERE balance >= $cost RETURNING balanceinside a transaction with the ledger (economy_transactions) row that justifies it — the row lock serializes concurrent debits on the same balance instead of both reading the same stalecurrent.grantis the equivalentINSERT ... ON CONFLICT DO UPDATE, backed byplayer_resource_balances's newCHECK (balance >= 0)(migration044_economy_balance_guard.sql) for the case where the credited resource has no row yet. The starting balance is seeded withON CONFLICT DO NOTHING(race-safe on its own — no window between "check" and "create"). Dragonfly is refreshed best-effort after commit purely as a warm cache; a cache-write failure never rolls back the already-committed transaction, and clearing it never resets a balance. Idempotency replay uses the existing partial unique index oneconomy_transactions (project_id, idempotency_key); a losing concurrent claim rolls its own speculative balance change back and returns the winner's stored result.routes/economy.rs's three public endpoints (balances/spend/grant) are now rate-limited (120/min per project+player,RateLimitService::allow_economy_action) — previously unlimited.gdpr.rs(Task 11,DATA-OPS-01/GAME-PATH-02):delete_player_dataerases a player across all twelve tables that carryexternal_user_id(progress, events, economy balances and ledger, game runs — commands cascade with them, fraud flags, manual fraud status, experiment cohorts, progress snapshots, and — since migration 051, block Г G1.1 — level results and the level progress summary) in one transaction — a partial failure used to leave a partial erasure with no record it happened.reward_claims(and thewebhook_deliveriespayload of its claims) is anonymized instead of deleted — the row is needed for accounting, soanonymized_ external_user_id(an HMAC keyed on the JWT secret, same reuse rationale asfraud_gate::identity::hash_client_address) replaces the identifier everywhere it appears, including inside the idempotency key strings that used to embed it as a literal substring — deliverystatusis never touched, since delivery and erasure are different facts. A durableplayer_erasure_jobsrow (migration045_gdpr_erasure_queue.sql), inserted in the same transaction as the SQL erasure, records the hot-storage half of the job; a Dragonfly outage at delete time reportscleanup_pendinginstead oferased, andjobs/gdpr_cleanup_queue.rsretries it.DragonflyPool::delete_keys_for_playernow also clears the exposure-bucket key and a bounded sweep ofstreak_daykeys — session and idempotency keys are not enumerable by player (no reverse index from player to those opaque keys) and are a documented gap, mitigated by their own TTLs. The audit log entry for an erasure stores only the same hash, never the raw identifier.analytics.rs,billing.rs,stripe.rs,onboarding.rs— autonomous-product services (stage 5). Bothanalytics.rsandbilling.rsrecognize a play starting on either event vocabulary —play.session_start(legacy widget path) orgame.started(game-run path, written bygame_run_progress::record_run_started) — so a played match3_v1 run is not invisible to the dashboard or to plan usage (FRAUD-03).billing::current_month_usage/generate_draft_invoicescompute monthly active users as oneCOUNT(DISTINCT external_user_id)over the whole period directly againstevents, not aSUMofaccount_usage_daily's per-day distinct counts (REWARDS-05/ADMIN-01): a day-by-day distinct count cannot be correctly re-aggregated into a monthly one after the fact. Anonymous identities (anon_..., minted fresh per request) are excluded from that enforcement query — they are tracked separately inaccount_usage_daily.anonymous_maufor visibility only — so a client cannot burn a paid plan's quota by just rotating identity (FRAUD-04).game_runs/— партии нового поколения:mod.rs(сервис и блокировка),repository.rs(SQL),types.rs(конверт запросов и ответов, канонический хэш команды),fixtures.rs(фикстуры клиента порождаются настоящим движком).execute_commandвызываетrewards::claim_game_won_rewardвнутри своей же транзакции команды, если среди фактов хода естьgame_won(Task 8, ANTIFRAUD.md §7 stage 6).build_claim_idempotency_keyдаётcampaign:userприmax_per_user ≤ 1; иначе суффиксrun_id, а приfirst_completion_only—<механика>:level:<level_id>. Повторы и круги дорожки не создают промо-заявок. Доставка (enqueue_reward_notification_tx) ставится в той же транзакции до commit: ошибка откатывает команду, результат и заявку.levels.rs— выбор уровня, progression и пересчёт сводки;lobby.rs— снимок лобби,color.rs— контрастный акцент,game_release.rs— входы общего выпуска клиента.game_run_progress.rs— подтверждённые факты и жизненный цикл партии одной транзакцией: событияgame.started,game.abandoned,game.<fact>и общий прогресс игрока.game_assets.rs— слоты из реестра, проверка файла, нормализация WebP840×1520≤220 КиБ, хранение оригинала и копии;widget_assets.rs— безопасная выдача собранных файлов клиента из образа.origin.rs,rate_limit.rs,schema_validate.rs,exposure.rs— supporting.origin.rs::origin_alloweddenies an emptyallowed_originslist for any game mode butfree_play(FRAUD-06);rate_limit.rs::allow_session_issueadds a 500/day cap per project+address to its existing 30/minute one.fraud_gate/identity.rs— trusted-proxy CIDR matching andresolve_client_ip;middleware::client_ip::normalize_client_ipapplies it before any handler runs (FRAUD-05).fraud.rs—record_fraud_flag(Task 9): 60s sliding-window dedup viaFraudStore::claim_flag_dedupbefore every insert, and the flag lifecycle columns (weight/ingress/rule_name/run_id/ip_hash/request_id/resolution).routes/admin_fraud.rsis the review surface — see FraudGate §"Разбор, метрики и гигиена флагов".jobs/fraud_retention_queue.rsdeletes resolved flags older than 90 days, hourly; unresolved flags are never touched.
Background jobs (src/jobs/)
Run by the playflow-worker binary (RUN_BACKGROUND_JOBS=false on the API in Swarm): hot_restore_queue, sync_queue, webhook_queue, billing_queue, reward_release_queue, plus fraud_retention_queue (Task 9), gdpr_cleanup_queue (Task 11) and data_retention_queue (Task 15).
game_run_expiry закрывает просроченные активные партии abandoned/expired: пачки по 500, FOR UPDATE SKIP LOCKED, heartbeat в job_heartbeats.
snapshot_queue and analytics_queue were removed in Task 15 step 1-2/15.2 (DATA-OPS-05/06): a codebase-wide search found zero readers of either progress_snapshots (the periodic snapshot sweep's only output) or template_analytics_daily (the hourly rollup's only output — the live dashboard reads raw events directly). Both backing tables are kept (GDPR erasure and rollback-safety, respectively) but nothing writes to them any more; data_retention_queue drains their already-written rows out over time instead of leaving them to grow forever unread.
Every job's loop runs under jobs::supervise (Task 13, GAME-PATH-05/OPS-05): a panic restarts it with bounded exponential backoff instead of silently exiting the process with code 0 (the old tokio::select! raced every job's JoinHandle and returned on whichever resolved first, panic or not). Exhausting the restart budget makes JobRunner::run return Err, which run_worker turns into a non-zero process exit — the signal Swarm's restart_policy: condition: on-failure actually restarts a container on. playflow-worker also serves its own /health//ready on WORKER_HEALTH_BIND (routes/worker_health.rs) — the only Swarm service that previously had no healthcheck at all.
reward_release_queue (Task 7) re-checks a held claim once its hold_until recorded at creation time has passed: it recomputes the current risk score (not the one from creation time) and only releases the claim — queuing its notification through rewards::dispatch_reward_notification — when that fresh score is back under risk_hold_threshold; still-elevated risk escalates the claim to review instead of either silently releasing it or leaving it stuck forever. Claims are atomically taken out of contention first (UPDATE ... FROM (SELECT ... FOR UPDATE SKIP LOCKED), the same shape webhook_queue::claim_pending_deliveries uses), and any error while processing one reverts it back to held rather than defaulting to release.
Auth
Admin uses httpOnly cookie sessions, scoped to Path=/v1/admin (не всему origin — /v1/play живёт на том же домене), с CSRF (Origin) checks (middleware::admin_csrf); server ingress uses scoped API keys (X-PlayFlow-Server-Key). Reward webhooks are HMAC-signed.
Second factor (TOTP) is mandatory once a session exists: middleware::two_factor_gate blocks every /v1/admin/* route except the auth/2FA bootstrap paths and /v1/admin/me until that session is marked verified in Dragonfly. enroll/confirm/challenge share one rate-limit bucket (services/two_factor/mod.rs::consume_totp_step), and each TOTP step can be consumed exactly once — admin_two_factor.last_used_step only ever moves forward, so two concurrent submissions of an intercepted code cannot both verify, and a valid code cannot be replayed for the rest of its ~90-second acceptance window.
Login and self-registration are rate-limited per email and per network address (services::rate_limit::RateLimitService::allow_admin_login / allow_admin_signup), failing closed (an unreachable limiter denies the request rather than allowing it). Self-registration is closed by default in production (ENABLE_SELF_REGISTRATION); the first production admin is created with playflow-admin create --email ADDRESS --password-stdin via docker exec against the running API container. Signup responds with the same 200 + account_id shape whether the email is new or already registered — the account_id for an already-registered email is a fresh, never-persisted UUID, not the real account, so the response cannot be used to enumerate registered emails or to log in as anyone.
Outbound webhook requests (real delivery and the admin "send test webhook" button alike) go through services::outbound_url::resolve_outbound_webhook_target before build_validated_client: the target IP is resolved and checked once, then pinned to that exact address for the request (no redirects, a fixed timeout), so a validated hostname cannot be silently re-resolved to a different, disallowed address between the check and the connection.
Tests
Unit + integration tests live alongside modules. Before any backend commit (D8):
cd backend
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warningsSQL/HTTP integration tests that need a real Postgres + Dragonfly live in backend/crates/api/tests/platform_*.rs, sharing the TestApp harness in tests/common/mod.rs. They are #[ignore]d so a plain cargo test skips them; run them against the canonical Swarm stack with:
bash scripts/run-platform-db-tests.shThis creates an isolated rp_platform_test_<id> database, runs the ignored tests, and drops that database afterwards — the working database, proj_demo and smoke fixtures are never touched. tests/platform-db-runner.test.sh checks the script's own defensive conditions (no stack, name collisions, zero tests run, a failing test run) against a fake docker/cargo.
Фикстуры браузерного клиента порождаются настоящим движком и не правятся руками:
cd backend
UPDATE_GAME_FIXTURES=1 cargo test -p playflow-api fixturesОбновление зависимостей от 4 сентября 2026 года
API использует Rust 1.99.0, SQLx 0.9, Redis-клиент 1.6 и Rand 0.10. Составные SQL-запросы собираются через QueryBuilder (построитель запросов): в текст добавляются только константы SQL, а пользовательские значения передаются через параметры bind. Для начального случайного числа партии используется системный источник SysRng; алгоритм воспроизводимого поля Match-3 и формат сохранённых партий не менялись.