Stage 6: gateway edge (Connect/FlatBuffers over h2c, platform/email/guest auth, sessions, rate-limit, admin passthrough, live push bridge)
Tests · Go / test (push) Successful in 8s
Tests · Integration / integration (push) Successful in 11s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 10s

New public ingress and the first network edge. Framework + a vertical slice of
operations end-to-end; remaining ops reuse the same transcode pattern in Stage 7.

Contracts (new module scrabble/pkg):
- push.proto (backend->gateway gRPC server-stream) + scrabble.fbs (FlatBuffers
  edge payloads), committed generated Go; buf/flatc Makefiles (dev-time codegen).

Backend:
- REST handlers on the /api/v1 groups: internal session endpoints
  (telegram/guest/email login -> mint, resolve, revoke) and the user slice
  (profile, submit_play, state, lobby enqueue/poll, chat).
- internal/notify in-process Publisher hub + internal/pushgrpc gRPC server
  (BACKEND_GRPC_ADDR) streaming your_turn/opponent_moved/chat/nudge/match_found;
  emission in game.commit, social, matchmaker.
- migration 00005 accounts.is_guest; guests are durable rows excluded from stats;
  ProvisionGuest; email-as-login (RequestLoginCode/LoginWithCode).

Gateway (new module scrabble/gateway):
- Connect Gateway service over h2c (Execute + Subscribe), FlatBuffers<->JSON
  transcode registry, Telegram initData HMAC validator (seam), session cache,
  token-bucket rate limiter (3 classes), push fan-out hub, backend REST + push
  gRPC client, admin Basic-Auth reverse proxy.

go.work: use ./pkg, ./gateway + replace scrabble/pkg. CI: gateway/**, pkg/**
path filters; unit build/vet/test span all three modules. Docs (PLAN,
ARCHITECTURE, FUNCTIONAL+ru, TESTING, READMEs) updated; gateway/pkg unit tests +
guest/email-login integration tests.
This commit is contained in:
Ilia Denisov
2026-06-02 22:38:24 +02:00
parent 104eb2a978
commit 408da3f201
98 changed files with 8134 additions and 57 deletions
+250
View File
@@ -0,0 +1,250 @@
package server
import (
"strings"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/social"
)
// The JSON DTOs below are the gateway<->backend REST contract. They are explicit
// (the domain/engine structs are never serialised directly) and mirror the
// FlatBuffers edge tables (pkg/fbs) the gateway transcodes to and from.
// sessionResponse is the credential returned by every auth endpoint.
type sessionResponse struct {
Token string `json:"token"`
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
DisplayName string `json:"display_name"`
}
// okResponse is a simple success acknowledgement.
type okResponse struct {
OK bool `json:"ok"`
}
// resolveResponse maps a session token to its account.
type resolveResponse struct {
UserID string `json:"user_id"`
}
// profileResponse is the authenticated account's own profile.
type profileResponse struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
HintBalance int `json:"hint_balance"`
BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"`
IsGuest bool `json:"is_guest"`
}
// tileDTO is one placed (or to-place) tile.
type tileDTO struct {
Row int `json:"row"`
Col int `json:"col"`
Letter string `json:"letter"`
Blank bool `json:"blank"`
}
// moveRecordDTO is a decoded move (a committed play, or a hint preview).
type moveRecordDTO struct {
Player int `json:"player"`
Action string `json:"action"`
Dir string `json:"dir"`
MainRow int `json:"main_row"`
MainCol int `json:"main_col"`
Tiles []tileDTO `json:"tiles"`
Words []string `json:"words"`
Count int `json:"count"`
Score int `json:"score"`
Total int `json:"total"`
}
// seatDTO is one seat's public standing.
type seatDTO struct {
Seat int `json:"seat"`
AccountID string `json:"account_id"`
Score int `json:"score"`
HintsUsed int `json:"hints_used"`
IsWinner bool `json:"is_winner"`
}
// gameDTO is the shared game summary.
type gameDTO struct {
ID string `json:"id"`
Variant string `json:"variant"`
DictVersion string `json:"dict_version"`
Status string `json:"status"`
Players int `json:"players"`
ToMove int `json:"to_move"`
TurnTimeoutSecs int `json:"turn_timeout_secs"`
MoveCount int `json:"move_count"`
EndReason string `json:"end_reason"`
Seats []seatDTO `json:"seats"`
}
// moveResultDTO is the outcome of a committed move.
type moveResultDTO struct {
Move moveRecordDTO `json:"move"`
Game gameDTO `json:"game"`
}
// stateDTO is a player's view of a game.
type stateDTO struct {
Game gameDTO `json:"game"`
Seat int `json:"seat"`
Rack []string `json:"rack"`
BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"`
}
// matchDTO reports whether the caller has been paired into a game.
type matchDTO struct {
Matched bool `json:"matched"`
Game *gameDTO `json:"game,omitempty"`
}
// chatDTO is one stored chat message or nudge.
type chatDTO struct {
ID string `json:"id"`
GameID string `json:"game_id"`
SenderID string `json:"sender_id"`
Kind string `json:"kind"`
Body string `json:"body"`
CreatedAtUnix int64 `json:"created_at_unix"`
}
// errorResponse is the uniform error envelope.
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// sessionResponseFor builds the credential payload for a minted session.
func sessionResponseFor(token string, acc account.Account) sessionResponse {
return sessionResponse{
Token: token,
UserID: acc.ID.String(),
IsGuest: acc.IsGuest,
DisplayName: acc.DisplayName,
}
}
// profileResponseFor projects an account into its profile DTO.
func profileResponseFor(acc account.Account) profileResponse {
return profileResponse{
UserID: acc.ID.String(),
DisplayName: acc.DisplayName,
PreferredLanguage: acc.PreferredLanguage,
TimeZone: acc.TimeZone,
HintBalance: acc.HintBalance,
BlockChat: acc.BlockChat,
BlockFriendRequests: acc.BlockFriendRequests,
IsGuest: acc.IsGuest,
}
}
// gameDTOFromGame projects a game.Game into its DTO.
func gameDTOFromGame(g game.Game) gameDTO {
seats := make([]seatDTO, 0, len(g.Seats))
for _, s := range g.Seats {
seats = append(seats, seatDTO{
Seat: s.Seat,
AccountID: s.AccountID.String(),
Score: s.Score,
HintsUsed: s.HintsUsed,
IsWinner: s.IsWinner,
})
}
return gameDTO{
ID: g.ID.String(),
Variant: g.Variant.String(),
DictVersion: g.DictVersion,
Status: g.Status,
Players: g.Players,
ToMove: g.ToMove,
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MoveCount: g.MoveCount,
EndReason: g.EndReason,
Seats: seats,
}
}
// moveRecordDTOFrom projects an engine move record into its DTO.
func moveRecordDTOFrom(m engine.MoveRecord) moveRecordDTO {
tiles := make([]tileDTO, 0, len(m.Tiles))
for _, t := range m.Tiles {
tiles = append(tiles, tileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
}
return moveRecordDTO{
Player: m.Player,
Action: m.Action.String(),
Dir: m.Dir.String(),
MainRow: m.MainRow,
MainCol: m.MainCol,
Tiles: tiles,
Words: m.Words,
Count: m.Count,
Score: m.Score,
Total: m.Total,
}
}
// moveResultDTOFrom projects a committed move result into its DTO.
func moveResultDTOFrom(r game.MoveResult) moveResultDTO {
return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)}
}
// stateDTOFrom projects a player's state view into its DTO.
func stateDTOFrom(v game.StateView) stateDTO {
return stateDTO{
Game: gameDTOFromGame(v.Game),
Seat: v.Seat,
Rack: v.Rack,
BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining,
}
}
// matchDTOFrom projects an enqueue/poll result into its DTO.
func matchDTOFrom(r lobby.EnqueueResult) matchDTO {
if !r.Matched {
return matchDTO{Matched: false}
}
g := gameDTOFromGame(r.Game)
return matchDTO{Matched: true, Game: &g}
}
// chatDTOFrom projects a chat message into its DTO.
func chatDTOFrom(m social.Message) chatDTO {
return chatDTO{
ID: m.ID.String(),
GameID: m.GameID.String(),
SenderID: m.SenderID.String(),
Kind: m.Kind,
Body: m.Body,
CreatedAtUnix: m.CreatedAt.Unix(),
}
}
// parseDirection maps the wire direction string to an engine.Direction.
func parseDirection(s string) (engine.Direction, bool) {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "H":
return engine.Horizontal, true
case "V":
return engine.Vertical, true
default:
return 0, false
}
}
+113
View File
@@ -0,0 +1,113 @@
package server
import (
"net/http"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/session"
"scrabble/backend/internal/social"
)
func TestParseDirection(t *testing.T) {
cases := map[string]struct {
in string
want engine.Direction
ok bool
}{
"horizontal": {"H", engine.Horizontal, true},
"vertical": {"V", engine.Vertical, true},
"lowercase": {"h", engine.Horizontal, true},
"trimmed": {" V ", engine.Vertical, true},
"invalid": {"X", 0, false},
"empty": {"", 0, false},
"diagonal-is-not": {"D", 0, false},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got, ok := parseDirection(tc.in)
if ok != tc.ok || (ok && got != tc.want) {
t.Fatalf("parseDirection(%q) = (%v, %v), want (%v, %v)", tc.in, got, ok, tc.want, tc.ok)
}
})
}
}
func TestStatusForError(t *testing.T) {
cases := map[string]struct {
err error
wantStatus int
wantCode string
}{
"not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"},
"not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"},
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
"unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
status, code := statusForError(tc.err)
if status != tc.wantStatus || code != tc.wantCode {
t.Fatalf("statusForError(%v) = (%d, %q), want (%d, %q)", tc.err, status, code, tc.wantStatus, tc.wantCode)
}
})
}
}
// context_deadline is an arbitrary unmapped error standing in for "anything
// unrecognised", which must fall through to 500/internal.
var context_deadline = errNew("boom")
type simpleErr string
func (e simpleErr) Error() string { return string(e) }
func errNew(s string) error { return simpleErr(s) }
func TestGameDTOFromGame(t *testing.T) {
gid, aid := uuid.New(), uuid.New()
g := game.Game{
ID: gid,
Variant: engine.VariantEnglish,
DictVersion: "v1",
Status: game.StatusActive,
Players: 2,
ToMove: 1,
TurnTimeout: 24 * time.Hour,
MoveCount: 3,
Seats: []game.Seat{{Seat: 0, AccountID: aid, Score: 12}},
}
dto := gameDTOFromGame(g)
if dto.ID != gid.String() || dto.Variant != "english" || dto.ToMove != 1 || dto.TurnTimeoutSecs != 86400 {
t.Fatalf("game dto mismatch: %+v", dto)
}
if len(dto.Seats) != 1 || dto.Seats[0].AccountID != aid.String() || dto.Seats[0].Score != 12 {
t.Fatalf("seat dto mismatch: %+v", dto.Seats)
}
}
func TestMoveRecordDTOFrom(t *testing.T) {
rec := engine.MoveRecord{
Player: 1,
Action: engine.ActionPlay,
Dir: engine.Vertical,
MainRow: 7,
MainCol: 7,
Tiles: []engine.TileRecord{{Row: 7, Col: 7, Letter: "A", Blank: false}},
Words: []string{"AB"},
Score: 10,
Total: 10,
}
dto := moveRecordDTOFrom(rec)
if dto.Action != "play" || dto.Dir != "V" || dto.Score != 10 || len(dto.Tiles) != 1 || dto.Tiles[0].Letter != "A" {
t.Fatalf("move dto mismatch: %+v", dto)
}
}
+133
View File
@@ -0,0 +1,133 @@
package server
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/session"
"scrabble/backend/internal/social"
)
// registerRoutes wires the Stage 6 REST handlers onto the /api/v1 groups. The
// internal group is gateway-only (the gateway authenticates and forwards); the
// user group requires X-User-ID; the admin group is reached through the gateway's
// Basic-Auth proxy. This is the representative vertical slice — further domain
// operations follow the same pattern (PLAN.md Stage 6).
func (s *Server) registerRoutes() {
if s.sessions != nil && s.accounts != nil {
in := s.internal
in.POST("/sessions/telegram", s.handleTelegramAuth)
in.POST("/sessions/guest", s.handleGuestAuth)
in.POST("/sessions/email/request", s.handleEmailRequest)
in.POST("/sessions/email/login", s.handleEmailLogin)
in.POST("/sessions/resolve", s.handleResolveSession)
in.POST("/sessions/revoke", s.handleRevokeSession)
}
u := s.user
if s.accounts != nil {
u.GET("/profile", s.handleProfile)
}
if s.games != nil {
u.POST("/games/:id/play", s.handleSubmitPlay)
u.GET("/games/:id/state", s.handleGameState)
}
if s.matchmaker != nil {
u.POST("/lobby/enqueue", s.handleEnqueue)
u.GET("/lobby/poll", s.handlePoll)
}
if s.social != nil {
u.POST("/games/:id/chat", s.handleChatPost)
}
s.admin.GET("/ping", s.handleAdminPing)
}
// userID returns the authenticated account id stored by RequireUserID. The user
// group always runs that middleware, so absence is a programming error.
func userID(c *gin.Context) (uuid.UUID, bool) {
return UserIDFromContext(c.Request.Context())
}
// gameIDParam parses the :id path parameter as a game UUID.
func gameIDParam(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
return uuid.UUID{}, false
}
return id, true
}
// clientIP returns the originating client IP the gateway forwarded in
// X-Forwarded-For (the first hop), falling back to the direct peer.
func clientIP(c *gin.Context) string {
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
return c.ClientIP()
}
// abortBadRequest rejects a malformed request body or parameter.
func abortBadRequest(c *gin.Context, msg string) {
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "bad_request", Message: msg}})
}
// abortErr maps a domain error to its HTTP status and a stable code. Server-side
// (5xx) errors are logged with the real cause and reported generically.
func (s *Server) abortErr(c *gin.Context, err error) {
status, code := statusForError(err)
msg := err.Error()
if status >= http.StatusInternalServerError {
s.log.Error("request failed", zap.String("path", c.FullPath()), zap.Error(err))
msg = "internal error"
}
c.AbortWithStatusJSON(status, errorResponse{Error: errorBody{Code: code, Message: msg}})
}
// statusForError maps a known domain sentinel to an HTTP status and code,
// defaulting to 500/internal for anything unrecognised.
func statusForError(err error) (int, string) {
switch {
case errors.Is(err, game.ErrNotFound), errors.Is(err, account.ErrNotFound):
return http.StatusNotFound, "not_found"
case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant):
return http.StatusForbidden, "not_a_player"
case errors.Is(err, game.ErrNotYourTurn), errors.Is(err, social.ErrNudgeOnOwnTurn):
return http.StatusConflict, "not_your_turn"
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
return http.StatusConflict, "game_finished"
case errors.Is(err, lobby.ErrAlreadyQueued):
return http.StatusConflict, "already_queued"
case errors.Is(err, game.ErrInvalidConfig):
return http.StatusBadRequest, "invalid_config"
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft), errors.Is(err, game.ErrNoHintAvailable):
return http.StatusConflict, "hint_unavailable"
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
return http.StatusUnprocessableEntity, "illegal_play"
case errors.Is(err, account.ErrEmailTaken):
return http.StatusConflict, "email_taken"
case errors.Is(err, account.ErrInvalidEmail):
return http.StatusBadRequest, "invalid_email"
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts):
return http.StatusUnauthorized, "code_invalid"
case errors.Is(err, session.ErrNotFound):
return http.StatusUnauthorized, "session_invalid"
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent),
errors.Is(err, social.ErrNudgeTooSoon):
return http.StatusUnprocessableEntity, "chat_rejected"
default:
return http.StatusInternalServerError, "internal"
}
}
+16
View File
@@ -0,0 +1,16 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
// The /api/v1/admin/* endpoints are reached through the gateway's Basic-Auth
// reverse proxy (docs/ARCHITECTURE.md §12). The backend trusts the gateway to
// have authenticated the operator; the admin surface itself (complaint review,
// dictionary versions) lands in Stage 9. handleAdminPing is the proxy target that
// proves the path end to end until then.
func (s *Server) handleAdminPing(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
+134
View File
@@ -0,0 +1,134 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"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 platform user id the gateway extracted from a
// validated initData payload.
type telegramAuthRequest struct {
ExternalID string `json:"external_id"`
}
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
// identity and mints a session for it.
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.ProvisionByIdentity(c.Request.Context(), account.KindTelegram, req.ExternalID)
if err != nil {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
}
// 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
}
c.JSON(http.StatusOK, resolveResponse{UserID: sess.AccountID.String()})
}
// 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))
}
+82
View File
@@ -0,0 +1,82 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/game"
"scrabble/backend/internal/session"
)
// newRoutingServer builds a Server with non-nil (zero-value) services so the
// routes register. The tests below exercise only the request-validation and
// routing layers, which return before any service method is called; full
// endpoint behaviour against real services is covered by the integration suite.
func newRoutingServer() *Server {
return New(":0", Deps{
Sessions: &session.Service{},
Accounts: &account.Store{},
Games: &game.Service{},
})
}
func do(t *testing.T, s *Server, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
var rdr *strings.Reader
if body != "" {
rdr = strings.NewReader(body)
} else {
rdr = strings.NewReader("")
}
req := httptest.NewRequest(method, path, rdr)
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
return rec
}
func TestAdminPingOK(t *testing.T) {
rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/admin/ping", "", nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"status":"ok"`) {
t.Fatalf("admin ping = %d %q", rec.Code, rec.Body.String())
}
}
func TestProfileRequiresUserID(t *testing.T) {
rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/user/profile", "", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("profile without X-User-ID = %d, want 401", rec.Code)
}
}
func TestResolveSessionRejectsEmptyToken(t *testing.T) {
rec := do(t, newRoutingServer(), http.MethodPost, "/api/v1/internal/sessions/resolve", `{}`, nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("resolve with empty token = %d, want 400", rec.Code)
}
}
func TestSubmitPlayRejectsBadDirection(t *testing.T) {
headers := map[string]string{"X-User-ID": uuid.New().String()}
path := "/api/v1/user/games/" + uuid.New().String() + "/play"
rec := do(t, newRoutingServer(), http.MethodPost, path, `{"dir":"X","tiles":[]}`, headers)
if rec.Code != http.StatusBadRequest {
t.Fatalf("submit play bad dir = %d, want 400", rec.Code)
}
}
func TestSubmitPlayRejectsBadGameID(t *testing.T) {
headers := map[string]string{"X-User-ID": uuid.New().String()}
rec := do(t, newRoutingServer(), http.MethodPost, "/api/v1/user/games/not-a-uuid/play", `{"dir":"H"}`, headers)
if rec.Code != http.StatusBadRequest {
t.Fatalf("submit play bad game id = %d, want 400", rec.Code)
}
}
+168
View File
@@ -0,0 +1,168 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"scrabble/backend/internal/engine"
)
// The /api/v1/user/* endpoints require X-User-ID (RequireUserID middleware). The
// backend treats that header as the sole identity input.
// handleProfile returns the authenticated account's own profile.
func (s *Server) handleProfile(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
acc, err := s.accounts.GetByID(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, profileResponseFor(acc))
}
// submitPlayRequest places tiles in a direction on the player's turn.
type submitPlayRequest struct {
Dir string `json:"dir"`
Tiles []struct {
Row int `json:"row"`
Col int `json:"col"`
Letter string `json:"letter"`
Blank bool `json:"blank"`
} `json:"tiles"`
}
// handleSubmitPlay validates, scores and commits a placement.
func (s *Server) handleSubmitPlay(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
gameID, ok := gameIDParam(c)
if !ok {
abortBadRequest(c, "invalid game id")
return
}
var req submitPlayRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
dir, ok := parseDirection(req.Dir)
if !ok {
abortBadRequest(c, "dir must be H or V")
return
}
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
for _, t := range req.Tiles {
tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
}
res, err := s.games.SubmitPlay(c.Request.Context(), gameID, uid, dir, tiles)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, moveResultDTOFrom(res))
}
// handleGameState returns the player's view of a game.
func (s *Server) handleGameState(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
gameID, ok := gameIDParam(c)
if !ok {
abortBadRequest(c, "invalid game id")
return
}
view, err := s.games.GameState(c.Request.Context(), gameID, uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, stateDTOFrom(view))
}
// enqueueRequest joins the per-variant auto-match pool.
type enqueueRequest struct {
Variant string `json:"variant"`
}
// handleEnqueue joins the auto-match pool for a variant.
func (s *Server) handleEnqueue(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req enqueueRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
variant, err := engine.ParseVariant(req.Variant)
if err != nil {
abortBadRequest(c, "unknown variant")
return
}
res, err := s.matchmaker.Enqueue(c.Request.Context(), uid, variant)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, matchDTOFrom(res))
}
// handlePoll reports whether the caller has been paired since queueing.
func (s *Server) handlePoll(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
res, err := s.matchmaker.Poll(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, matchDTOFrom(res))
}
// chatPostRequest posts a per-game chat message.
type chatPostRequest struct {
Body string `json:"body"`
}
// handleChatPost stores a chat message from the authenticated player. The sender
// IP is taken from the gateway-forwarded X-Forwarded-For header.
func (s *Server) handleChatPost(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
gameID, ok := gameIDParam(c)
if !ok {
abortBadRequest(c, "invalid game id")
return
}
var req chatPostRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
msg, err := s.social.PostMessage(c.Request.Context(), gameID, uid, req.Body, clientIP(c))
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, chatDTOFrom(msg))
}
+16 -4
View File
@@ -18,7 +18,9 @@ import (
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/session"
"scrabble/backend/internal/social"
"scrabble/backend/internal/telemetry"
)
@@ -42,10 +44,13 @@ type Deps struct {
// SessionsReady reports whether the session cache has been warmed. A nil
// func skips the session-readiness check.
SessionsReady func() bool
// Social, Matchmaker, Invitations and Emails are the Stage 4 domain services.
// They are held for the REST/stream handlers the gateway adds in Stage 6 (like
// the route groups, this is scaffolding exposed via accessors); the server
// itself does not route to them yet.
// Sessions, Accounts and Games are the identity, account and game-domain
// services the Stage 6 REST handlers route to.
Sessions *session.Service
Accounts *account.Store
Games *game.Service
// Social, Matchmaker, Invitations and Emails are the Stage 4 domain services
// the Stage 6 REST handlers route to.
Social *social.Service
Matchmaker *lobby.Matchmaker
Invitations *lobby.InvitationService
@@ -61,6 +66,9 @@ type Server struct {
pingTimeout time.Duration
sessionsReady func() bool
sessions *session.Service
accounts *account.Store
games *game.Service
social *social.Service
matchmaker *lobby.Matchmaker
invitations *lobby.InvitationService
@@ -94,6 +102,9 @@ func New(addr string, deps Deps) *Server {
db: deps.DB,
pingTimeout: pingTimeout,
sessionsReady: deps.SessionsReady,
sessions: deps.Sessions,
accounts: deps.Accounts,
games: deps.Games,
social: deps.Social,
matchmaker: deps.Matchmaker,
invitations: deps.Invitations,
@@ -102,6 +113,7 @@ func New(addr string, deps Deps) *Server {
}
s.registerProbes(engine)
s.registerAPIGroups(engine)
s.registerRoutes()
return s
}