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_archiveimmutable; уехавшие в аналитику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_idguest→user в истории — ломает immutable-архивы и уже доставленный ингест; линковка дешевле и обратима.