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
}
}