ADR-0017 · Аккаунты игроков и вход через Google

Статус: предложено · 2026-08-02

Контекст. Playsite анонимный-first: единственная идентичность человека — guest:<uuidv7> в localStorage (ADR-0003), рейтинг Glicko-2 есть только у ботов, а на аккаунты заложен спящий фундамент: Principal.User("user:<uuid>") в enum play-api (round-trip в кодеках, isRated уже считает его рейтингуемым), колонка bots.owner_external_id (V4, «forward-looking owner slot») и стаб authStore в SPA («replaced by a real auth store issuing user:<uuid> identities», roadmap phase 3). Мотивация включить аккаунты сейчас: рейтинг людям, самообслуживание владельцев ботов (сегодня — только curl с Bearer-токеном), в перспективе турниры (ADR-0009). Готовый донор кода — незамёрженная ветка task/213-google-oauth (PR #215) в dicechess-analytics: hardened-порт модели dicechess-lab (JWKS-верификация id_token с проверкой issuer/audience/email_verified, CSRF state-cookie с constant-time сравнением, HMAC-JWT cookie-сессии с перепроверкой юзера в БД на каждый запрос).

Решение.

  • Флоу: Google OAuth authorization-code целиком на play-api (GET /auth/login → Google → GET /auth/callback → cookie → redirect на FRONTEND_URL). Код портируется из ветки PR #215; сам PR в analytics не мёржится (dca закрыт Cloudflare Access, ему in-app auth не нужен).
  • Сессии: HMAC-JWT в HttpOnly-cookie access_token (host-only на play-api.jc.id.lv, SameSite=Lax, Secure, 30 дней), юзер перечитывается из БД на каждый запрос (мгновенный revoke). play.jc.id.lv и play-api.jc.id.lv — same-site, поэтому Lax не мешает ни fetch, ни WS-handshake. CORS переходит на withAllowCredentials(true) — только при явном allowlist origins. Это осознанный отход от «no ambient credentials» (Cors.scala): у аккаунта появляются привилегии (боты, потом турниры), Bearer в localStorage крадётся любым XSS.
  • Ключ идентичности: users.id = UUID, выданный нами. Google привязывается через user_identities (provider, subject) → user_id; email — изменчивый атрибут, не ключ (урок lab/analytics, где ключ — email, а sub не хранится вовсе). Мультипровайдерность схемой не блокируется, но не реализуется.
  • Ник: обязательный, уникальный (case-insensitive), генерируется автоматически при первом входе (словарный генератор dice/chess-тематики, LuckyRook417), редактируемый. Реальное имя и email из Google никогда не публичны.
  • Гостевая история: линкуется, не переписывается — user_guest_links (guest_id UNIQUE → user_id); история юзера = union по user:<uuid> и слинкованным guest:<uuid>. game_results/game_archive immutable; уехавшие в аналитику guest:-записи не трогаем (разрыв предусмотрен ADR-0003: user:-префикс существовал с самого начала). Один guest id — ровно один аккаунт; доказательство владения = предъявление id (как restore-код).
  • Rated: гости — никогда (reset identity = бесплатный фарм). User-vs-user — да. User-vs-bot — только против кураторских (operator-flagged) ботов; против собственного бота — никогда. Шкала одна, общая с ботами (Glicko-2 1500, решает cold start: HvH-трафика мало, боты доступны всегда).
  • Регистрация открытая — approval-workflow из analytics не переносится. Анонимный путь не деградирует: аккаунт — опция ради рейтинга и ботов.
  • Идиома выключателя: auth — DB-only фича; без PLAY_DB_URL или Google-переменных роуты отсутствуют (404), как catalog/leaderboard.

Последствия.

  • SPA: реальный authStore (стаб уже зеркалит нужный интерфейс), credentials: 'include' в затронутых fetch, онбординг ника + предложение клейма текущей гостевой истории, обновление копирайта («no sign-up» → «sign in only if you want a rating»).
  • Identity в игру приходит из сессии (cookie), а не из body — body-поля white/creator/guestId остаются гостевым fallback. Бонус: залогиненный участник восстанавливает своё место в live-игре без ?seat=-URL (сегодня потеря URL = навсегда зритель).
  • Удаление аккаунта self-service: сносятся users/identities/links, user:<uuid> в архивах становится неразрешимым псевдонимом — анонимизация без переписывания истории.
  • Превью-деплои *.pages.dev — cross-site: логин там не работает (они и так порезаны CORS).
  • Wire-контракт: PublicPlayer.name для зарегистрированных = ник (гости по-прежнему null/анонимны).

Альтернативы (отклонены).

  • Google Identity Services на клиенте (ID-token без редиректов) — тянет внешний скрипт в self-contained PWA и требует нового кода вместо готового hardened-порта.
  • Bearer-токен в localStorage — консистентно с текущей философией, но XSS-кража токена при растущих привилегиях аккаунта; HttpOnly-cookie надёжнее.
  • Ключ идентичности = email — email меняется; повторять упрощение lab/analytics не стали.
  • Переписывание external_id guest→user в истории — ломает immutable-архивы и уже доставленный ингест; линковка дешевле и обратима.

🔗 ADR-0003-Identichnost-playsite · ADR-0009-Bot-API-i-turniry · ADR-0010-Anonimnye-self-service-boty · ADR-0014-Katalog-botov-igra-s-chelovekom