package server import ( "errors" "net/http" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/notify" ) // 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; BrowserTZ (the client's detected // "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch // deep-link payload, which may seed the new account's variant preferences (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"` BrowserTZ string `json:"browser_tz"` StartParam string `json:"start_param"` } // 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, created, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName, req.BrowserTZ) if err != nil { s.abortErr(c, err) return } if created { // First registration: re-evaluate moderated-chat write access, so a user who // joined the chat before registering is granted on the spot (no chat_member // event fires on registration). s.publishChatAccessChange(acc.ID) // A promo deep-link may seed this brand-new account's variant preferences (e.g. // English Scrabble alongside the default Erudit). Best-effort: an absent or // malformed payload leaves the account on its defaults, and a write failure must // not block the session mint. if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 { if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil { s.log.Warn("telegram: seed variant preferences failed", zap.String("account", acc.ID.String()), zap.Error(err)) } } } s.mintSession(c, acc) } // vkAuthRequest carries the identity the gateway extracted from verified VK launch // params. LanguageCode (vk_language) and DisplayName (read client-side via // VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new // account's language and display name; BrowserTZ (the client's detected "±HH:MM" UTC // offset) seeds its time zone. All seeds apply on first contact only. type vkAuthRequest struct { ExternalID string `json:"external_id"` LanguageCode string `json:"language_code"` DisplayName string `json:"display_name"` BrowserTZ string `json:"browser_tz"` } // handleVKAuth provisions (or finds) the account bound to a VK identity and mints a // session for it, seeding a new account's language and display name from the supplied VK // fields (first contact only). Unlike Telegram there is no moderated-chat re-evaluation // or deep-link variant seed: a fresh VK account has no Telegram chat eligibility and the // MVP carries no launch deep link. func (s *Server) handleVKAuth(c *gin.Context) { var req vkAuthRequest if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" { abortBadRequest(c, "external_id is required") return } acc, _, err := s.accounts.ProvisionVK(c.Request.Context(), req.ExternalID, req.LanguageCode, req.DisplayName, req.BrowserTZ) 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, }) } // guestAuthRequest carries the guest bootstrap's optional time-zone seed: BrowserTZ // (the client's detected "±HH:MM" UTC offset) is written to the new guest account's // time zone, so robot timing is anchored to the player's zone from the first game. type guestAuthRequest struct { BrowserTZ string `json:"browser_tz"` } // handleGuestAuth provisions a fresh ephemeral guest account and mints a session, // seeding its time zone from the optional detected browser offset. func (s *Server) handleGuestAuth(c *gin.Context) { // The body is optional: an absent or malformed one simply yields no time-zone seed // (the account keeps the UTC default), so a bind error must not fail the bootstrap. var req guestAuthRequest _ = c.ShouldBindJSON(&req) acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ) if err != nil { s.abortErr(c, err) return } s.mintSession(c, acc) } // emailRequest is an email-login code request. BrowserTZ (the client's detected // "±HH:MM" UTC offset) seeds the time zone of an account provisioned here on first // contact (the email account is created at the request step, not at login). type emailRequest struct { Email string `json:"email"` BrowserTZ string `json:"browser_tz"` Language string `json:"language"` } // 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, req.BrowserTZ, req.Language); 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) } // confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login, // Session carries the freshly minted credential (the deeplink page signs in with it); // for a link, Status is "confirmed" or "merge_required" (the app completes the merge). type confirmLinkResponse struct { Purpose string `json:"purpose"` Status string `json:"status"` Session *sessionResponse `json:"session,omitempty"` } // handleEmailConfirmLink verifies a one-tap deeplink token. A login mints a session // (the deeplink page signs in with it); a link attaches the confirmed email, reporting // "merge_required" when the address is owned by another account so the app drives the // interactive merge. The token, not a request session, is the authorization — the page // need not be signed in. func (s *Server) handleEmailConfirmLink(c *gin.Context) { var req tokenRequest if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" { abortBadRequest(c, "token is required") return } res, err := s.emails.ConfirmByToken(c.Request.Context(), req.Token) if err != nil { s.abortErr(c, err) return } if res.IsLogin() { acc, err := s.accounts.GetByID(c.Request.Context(), res.Account) if err != nil { s.abortErr(c, err) return } token, _, err := s.sessions.Create(c.Request.Context(), acc.ID) if err != nil { s.abortErr(c, err) return } sess := sessionResponseFor(token, acc) c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "login", Status: "confirmed", Session: &sess}) return } if res.NeedsMerge { c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "merge_required"}) return } // A free link attached the email; nudge the account's in-app session(s) to re-fetch // the profile, so a link confirmed in another browser reflects at once. s.notifier.Publish(notify.ProfileChanged(res.Account)) c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "confirmed"}) } // 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)) }