762155a55e
Add the confirm-link edge method (auth.email.confirm_link): a new EmailConfirmLinkRequest/Result fbs table, the transcode const + handler + encoder, and the backend-client call to the existing /sessions/email/confirm-link endpoint — it rides Execute under the existing service prefix, so no proto/Caddy change. Add a language field to EmailRequestRequest and forward it (the backend already seeds it). Add the NotifyProfile sub-kind + notify.ProfileChanged, published by the confirm-link handler on a successful link so an in-app session re-fetches its profile when the email was confirmed in another browser. Regenerated fbs bindings (Go + TS).
317 lines
12 KiB
Go
317 lines
12 KiB
Go
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))
|
|
}
|