Files
scrabble-game/backend/internal/server/handlers_auth.go
T
Ilia Denisov 57c778f9b2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
feat(telegram,game): single bot + per-user variant preferences
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.

- Telegram: one bot; drop service_language and the supported_languages
  set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
  connector proto). The single bot renders chat and out-of-app push in
  the recipient's preferred_language; remove the game-language push
  routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
  {erudit_ru}, CHECK non-empty + subset of the three variants). Gates
  the New Game picker, vs-AI and the friend invitation the player
  creates, enforced server-side (HTTP 400 otherwise); an invited friend
  may still accept any variant. Edited on the Settings screen; variants
  are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
  and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
  VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
  to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
  backend, ui, UI_DESIGN, PRERELEASE).

The migration squash is deferred to a follow-up PR.
2026-06-20 14:23:25 +02:00

197 lines
6.5 KiB
Go

package server
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
// already validated the originating credential (Telegram initData, an email
// code, or a guest bootstrap) and forwards the result here to provision the
// account and mint the opaque session. The backend trusts the gateway on this
// segment (docs/ARCHITECTURE.md §12).
// telegramAuthRequest carries the identity the connector extracted from a
// validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language (first contact only).
type telegramAuthRequest struct {
ExternalID string `json:"external_id"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"`
}
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
// identity and mints a session for it, seeding a new account's display name and
// language from the supplied Telegram fields (first contact only).
func (s *Server) handleTelegramAuth(c *gin.Context) {
var req telegramAuthRequest
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "external_id is required")
return
}
acc, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName)
if err != nil {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
}
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
type pushTargetRequest struct {
UserID string `json:"user_id"`
}
// pushTargetResponse carries what the gateway needs to route an out-of-app push:
// the recipient's Telegram external_id (empty when they have no Telegram
// identity, e.g. a guest or email-only account), the language the single bot renders
// the message in (the account's interface language), and whether they confined
// notifications to the in-app stream.
type pushTargetResponse struct {
ExternalID string `json:"external_id"`
Language string `json:"language"`
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
}
// handlePushTarget resolves a user id to the data the gateway needs to deliver an
// out-of-app Telegram notification — the gateway-only internal counterpart of the
// in-app push stream. A user with no Telegram identity yields an empty external_id,
// which the gateway treats as "no out-of-app channel".
func (s *Server) handlePushTarget(c *gin.Context) {
var req pushTargetRequest
if err := c.ShouldBindJSON(&req); err != nil || req.UserID == "" {
abortBadRequest(c, "user_id is required")
return
}
uid, err := uuid.Parse(req.UserID)
if err != nil {
abortBadRequest(c, "user_id must be a uuid")
return
}
acc, err := s.accounts.GetByID(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
ext, err := s.accounts.IdentityExternalID(c.Request.Context(), uid, account.KindTelegram)
if err != nil && !errors.Is(err, account.ErrNotFound) {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, pushTargetResponse{
ExternalID: ext,
Language: acc.PreferredLanguage,
NotificationsInAppOnly: acc.NotificationsInAppOnly,
})
}
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session.
func (s *Server) handleGuestAuth(c *gin.Context) {
acc, err := s.accounts.ProvisionGuest(c.Request.Context())
if err != nil {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
}
// emailRequest is an email-login code request.
type emailRequest struct {
Email string `json:"email"`
}
// handleEmailRequest issues a login confirm-code to the email. It always reports
// success once the address is well-formed, so the response does not reveal
// whether an account already exists.
func (s *Server) handleEmailRequest(c *gin.Context) {
var req emailRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Email == "" {
abortBadRequest(c, "email is required")
return
}
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// emailLoginRequest verifies an email login code.
type emailLoginRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
// handleEmailLogin verifies the code and mints a session for the owning account.
func (s *Server) handleEmailLogin(c *gin.Context) {
var req emailLoginRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Email == "" || req.Code == "" {
abortBadRequest(c, "email and code are required")
return
}
acc, err := s.emails.LoginWithCode(c.Request.Context(), req.Email, req.Code)
if err != nil {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
}
// tokenRequest carries an opaque session token.
type tokenRequest struct {
Token string `json:"token"`
}
// handleResolveSession resolves a token to its account id. The gateway calls it
// on a session-cache miss.
func (s *Server) handleResolveSession(c *gin.Context) {
var req tokenRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
abortBadRequest(c, "token is required")
return
}
sess, err := s.sessions.Resolve(c.Request.Context(), req.Token)
if err != nil {
s.abortErr(c, err)
return
}
// is_guest is best-effort: a transient account read must not fail an otherwise
// valid resolve (the auth hot path), so a read error falls back to false; the
// per-operation backend gate remains the authoritative guest check.
resp := resolveResponse{UserID: sess.AccountID.String()}
if acc, err := s.accounts.GetByID(c.Request.Context(), sess.AccountID); err == nil {
resp.IsGuest = acc.IsGuest
}
c.JSON(http.StatusOK, resp)
}
// handleRevokeSession revokes the session for a token (idempotent).
func (s *Server) handleRevokeSession(c *gin.Context) {
var req tokenRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
abortBadRequest(c, "token is required")
return
}
if err := s.sessions.Revoke(c.Request.Context(), req.Token); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// mintSession creates a session for acc and writes the credential response.
func (s *Server) mintSession(c *gin.Context, acc account.Account) {
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, sessionResponseFor(token, acc))
}