Stage 6: gateway edge (Connect/FlatBuffers over h2c, platform/email/guest auth, sessions, rate-limit, admin passthrough, live push bridge)
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user