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,64 @@
|
||||
// Package admin is the gateway's admin surface: HTTP Basic-Auth in front of a
|
||||
// reverse proxy to the backend admin API (docs/ARCHITECTURE.md §12). The gateway
|
||||
// validates the operator credential and forwards authenticated requests to
|
||||
// backend /api/v1/admin/*; the backend trusts the gateway on this segment. The
|
||||
// admin API itself is filled in Stage 9.
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// backendAdminPrefix is where the backend mounts its admin API.
|
||||
const backendAdminPrefix = "/api/v1/admin"
|
||||
|
||||
// NewProxy returns a handler that checks Basic-Auth against user/password and
|
||||
// reverse-proxies the request to the backend admin API, mapping an inbound
|
||||
// /admin/<rest> path to <backendURL>/api/v1/admin/<rest>.
|
||||
func NewProxy(backendURL, user, password string, log *zap.Logger) (http.Handler, error) {
|
||||
target, err := url.Parse(backendURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admin: parse backend url %q: %w", backendURL, err)
|
||||
}
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(target)
|
||||
rel := strings.TrimPrefix(pr.In.URL.Path, "/admin")
|
||||
pr.Out.URL.Path = backendAdminPrefix + rel
|
||||
pr.Out.Host = pr.In.Host
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Warn("admin proxy upstream error", zap.String("path", r.URL.Path), zap.Error(err))
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
},
|
||||
}
|
||||
return basicAuth(user, password, proxy), nil
|
||||
}
|
||||
|
||||
// basicAuth wraps next with a constant-time Basic-Auth check.
|
||||
func basicAuth(user, password string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok || !equal(u, user) || !equal(p, password) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="scrabble-admin"`)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// equal compares two strings in constant time.
|
||||
func equal(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"scrabble/gateway/internal/admin"
|
||||
)
|
||||
|
||||
func newAdmin(t *testing.T) (*httptest.Server, func()) {
|
||||
t.Helper()
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/admin/ping" {
|
||||
t.Errorf("backend path = %q, want /api/v1/admin/ping", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte("pong"))
|
||||
}))
|
||||
proxy, err := admin.NewProxy(backend.URL, "ops", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new proxy: %v", err)
|
||||
}
|
||||
front := httptest.NewServer(proxy)
|
||||
return front, func() { front.Close(); backend.Close() }
|
||||
}
|
||||
|
||||
func TestAdminRejectsMissingCredentials(t *testing.T) {
|
||||
front, cleanup := newAdmin(t)
|
||||
defer cleanup()
|
||||
|
||||
resp, err := http.Get(front.URL + "/admin/ping")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminProxiesWithCredentials(t *testing.T) {
|
||||
front, cleanup := newAdmin(t)
|
||||
defer cleanup()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, front.URL+"/admin/ping", nil)
|
||||
req.SetBasicAuth("ops", "secret")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK || string(body) != "pong" {
|
||||
t.Fatalf("status = %d body = %q, want 200 pong", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRejectsWrongPassword(t *testing.T) {
|
||||
front, cleanup := newAdmin(t)
|
||||
defer cleanup()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, front.URL+"/admin/ping", nil)
|
||||
req.SetBasicAuth("ops", "wrong")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package auth holds the gateway's credential validators. The only non-trivial
|
||||
// one is the Telegram Web App initData HMAC check; guest and email logins carry
|
||||
// no gateway-side secret and are validated by the backend. The validator is an
|
||||
// interface so handlers test against fixtures without a bot token.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
|
||||
// the hash, is malformed, or is older than the freshness window.
|
||||
var ErrInvalidInitData = errors.New("auth: invalid telegram init data")
|
||||
|
||||
// defaultMaxAge bounds how old a validated initData payload may be.
|
||||
const defaultMaxAge = 24 * time.Hour
|
||||
|
||||
// TelegramUser is the identity extracted from a validated initData payload. ID
|
||||
// is the platform user id used as the identity's external_id.
|
||||
type TelegramUser struct {
|
||||
ID string
|
||||
Username string
|
||||
FirstName string
|
||||
}
|
||||
|
||||
// TelegramValidator validates Telegram Web App launch data and returns the
|
||||
// authenticated user.
|
||||
type TelegramValidator interface {
|
||||
Validate(initData string) (TelegramUser, error)
|
||||
}
|
||||
|
||||
// HMACValidator validates initData against a bot token per Telegram's documented
|
||||
// algorithm: the data-check string is HMAC-SHA256'd under a secret derived from
|
||||
// the bot token, and the result is compared with the supplied hash.
|
||||
type HMACValidator struct {
|
||||
botToken string
|
||||
maxAge time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewHMACValidator constructs a validator for botToken.
|
||||
func NewHMACValidator(botToken string) *HMACValidator {
|
||||
return &HMACValidator{botToken: botToken, maxAge: defaultMaxAge, now: time.Now}
|
||||
}
|
||||
|
||||
// Validate parses and verifies initData, returning the authenticated user.
|
||||
func (v *HMACValidator) Validate(initData string) (TelegramUser, error) {
|
||||
values, err := url.ParseQuery(initData)
|
||||
if err != nil {
|
||||
return TelegramUser{}, ErrInvalidInitData
|
||||
}
|
||||
hash := values.Get("hash")
|
||||
if hash == "" {
|
||||
return TelegramUser{}, ErrInvalidInitData
|
||||
}
|
||||
values.Del("hash")
|
||||
|
||||
if !v.checkSignature(values, hash) {
|
||||
return TelegramUser{}, ErrInvalidInitData
|
||||
}
|
||||
if err := v.checkFreshness(values.Get("auth_date")); err != nil {
|
||||
return TelegramUser{}, err
|
||||
}
|
||||
return parseUser(values.Get("user"))
|
||||
}
|
||||
|
||||
// checkSignature recomputes the HMAC over the sorted data-check string and
|
||||
// compares it with hash in constant time.
|
||||
func (v *HMACValidator) checkSignature(values url.Values, hash string) bool {
|
||||
keys := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
lines = append(lines, k+"="+values.Get(k))
|
||||
}
|
||||
dataCheck := strings.Join(lines, "\n")
|
||||
|
||||
secret := hmacSHA256([]byte("WebAppData"), []byte(v.botToken))
|
||||
want := hmacSHA256(secret, []byte(dataCheck))
|
||||
got, err := hex.DecodeString(hash)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return hmac.Equal(want, got)
|
||||
}
|
||||
|
||||
// checkFreshness rejects an auth_date older than the validator's window.
|
||||
func (v *HMACValidator) checkFreshness(authDate string) error {
|
||||
if authDate == "" {
|
||||
return ErrInvalidInitData
|
||||
}
|
||||
secs, err := strconv.ParseInt(authDate, 10, 64)
|
||||
if err != nil {
|
||||
return ErrInvalidInitData
|
||||
}
|
||||
if v.now().Sub(time.Unix(secs, 0)) > v.maxAge {
|
||||
return ErrInvalidInitData
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseUser extracts the user id and names from the user JSON field.
|
||||
func parseUser(userJSON string) (TelegramUser, error) {
|
||||
if userJSON == "" {
|
||||
return TelegramUser{}, ErrInvalidInitData
|
||||
}
|
||||
var u struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
|
||||
return TelegramUser{}, ErrInvalidInitData
|
||||
}
|
||||
return TelegramUser{
|
||||
ID: strconv.FormatInt(u.ID, 10),
|
||||
Username: u.Username,
|
||||
FirstName: u.FirstName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// hmacSHA256 returns HMAC-SHA256(message) under key.
|
||||
func hmacSHA256(key, message []byte) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write(message)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scrabble/gateway/internal/auth"
|
||||
)
|
||||
|
||||
// signedInitData builds a valid Telegram initData query string for botToken,
|
||||
// computing the hash exactly as Telegram does.
|
||||
func signedInitData(botToken string, fields map[string]string) string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
lines = append(lines, k+"="+fields[k])
|
||||
}
|
||||
secretMAC := hmac.New(sha256.New, []byte("WebAppData"))
|
||||
secretMAC.Write([]byte(botToken))
|
||||
secret := secretMAC.Sum(nil)
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte(strings.Join(lines, "\n")))
|
||||
hash := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
v := url.Values{}
|
||||
for k, val := range fields {
|
||||
v.Set(k, val)
|
||||
}
|
||||
v.Set("hash", hash)
|
||||
return v.Encode()
|
||||
}
|
||||
|
||||
func TestValidateAcceptsGenuineInitData(t *testing.T) {
|
||||
const token = "test-bot-token"
|
||||
fields := map[string]string{
|
||||
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||
"query_id": "abc",
|
||||
"user": `{"id":42,"first_name":"Ann","username":"ann"}`,
|
||||
}
|
||||
u, err := auth.NewHMACValidator(token).Validate(signedInitData(token, fields))
|
||||
if err != nil {
|
||||
t.Fatalf("validate genuine: %v", err)
|
||||
}
|
||||
if u.ID != "42" || u.Username != "ann" {
|
||||
t.Fatalf("user = %+v", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsTamperedHash(t *testing.T) {
|
||||
const token = "test-bot-token"
|
||||
fields := map[string]string{
|
||||
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||
"user": `{"id":42}`,
|
||||
}
|
||||
data := signedInitData(token, fields) + "0" // corrupt the trailing hash
|
||||
if _, err := auth.NewHMACValidator(token).Validate(data); err == nil {
|
||||
t.Fatal("expected rejection of tampered init data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsWrongToken(t *testing.T) {
|
||||
fields := map[string]string{
|
||||
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||
"user": `{"id":42}`,
|
||||
}
|
||||
data := signedInitData("real-token", fields)
|
||||
if _, err := auth.NewHMACValidator("other-token").Validate(data); err == nil {
|
||||
t.Fatal("expected rejection under a different bot token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsStaleInitData(t *testing.T) {
|
||||
const token = "test-bot-token"
|
||||
fields := map[string]string{
|
||||
"auth_date": strconv.FormatInt(time.Now().Add(-48*time.Hour).Unix(), 10),
|
||||
"user": `{"id":42}`,
|
||||
}
|
||||
if _, err := auth.NewHMACValidator(token).Validate(signedInitData(token, fields)); err == nil {
|
||||
t.Fatal("expected rejection of stale init data")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package backendclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// The structs below mirror the backend's JSON DTOs (backend/internal/server
|
||||
// /dto.go). The transcode layer maps them to and from the FlatBuffers edge
|
||||
// payloads.
|
||||
|
||||
// SessionResp is the credential minted by an auth operation.
|
||||
type SessionResp struct {
|
||||
Token string `json:"token"`
|
||||
UserID string `json:"user_id"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// ProfileResp is an account's own profile.
|
||||
type ProfileResp 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"`
|
||||
}
|
||||
|
||||
// TileJSON is one placed tile, used in both play requests and move responses.
|
||||
type TileJSON struct {
|
||||
Row int `json:"row"`
|
||||
Col int `json:"col"`
|
||||
Letter string `json:"letter"`
|
||||
Blank bool `json:"blank"`
|
||||
}
|
||||
|
||||
// MoveRecordResp is a decoded move.
|
||||
type MoveRecordResp struct {
|
||||
Player int `json:"player"`
|
||||
Action string `json:"action"`
|
||||
Dir string `json:"dir"`
|
||||
MainRow int `json:"main_row"`
|
||||
MainCol int `json:"main_col"`
|
||||
Tiles []TileJSON `json:"tiles"`
|
||||
Words []string `json:"words"`
|
||||
Count int `json:"count"`
|
||||
Score int `json:"score"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// SeatResp is one seat's public standing.
|
||||
type SeatResp struct {
|
||||
Seat int `json:"seat"`
|
||||
AccountID string `json:"account_id"`
|
||||
Score int `json:"score"`
|
||||
HintsUsed int `json:"hints_used"`
|
||||
IsWinner bool `json:"is_winner"`
|
||||
}
|
||||
|
||||
// GameResp is the shared game summary.
|
||||
type GameResp 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 []SeatResp `json:"seats"`
|
||||
}
|
||||
|
||||
// MoveResultResp is the outcome of a committed move.
|
||||
type MoveResultResp struct {
|
||||
Move MoveRecordResp `json:"move"`
|
||||
Game GameResp `json:"game"`
|
||||
}
|
||||
|
||||
// StateResp is a player's view of a game.
|
||||
type StateResp struct {
|
||||
Game GameResp `json:"game"`
|
||||
Seat int `json:"seat"`
|
||||
Rack []string `json:"rack"`
|
||||
BagLen int `json:"bag_len"`
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
}
|
||||
|
||||
// MatchResp reports an auto-match outcome.
|
||||
type MatchResp struct {
|
||||
Matched bool `json:"matched"`
|
||||
Game *GameResp `json:"game,omitempty"`
|
||||
}
|
||||
|
||||
// ChatResp is a stored chat message.
|
||||
type ChatResp 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"`
|
||||
}
|
||||
|
||||
// TelegramAuth provisions/finds the Telegram account and mints a session.
|
||||
func (c *Client) TelegramAuth(ctx context.Context, externalID string) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
||||
map[string]string{"external_id": externalID}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GuestAuth provisions a guest account and mints a session.
|
||||
func (c *Client) GuestAuth(ctx context.Context) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "", struct{}{}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// EmailRequest asks the backend to mail a login code.
|
||||
func (c *Client) EmailRequest(ctx context.Context, email string) error {
|
||||
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
|
||||
map[string]string{"email": email}, nil)
|
||||
}
|
||||
|
||||
// EmailLogin verifies a login code and mints a session.
|
||||
func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/login", "", "",
|
||||
map[string]string{"email": email, "code": code}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ResolveSession maps a token to its account id (gateway session-cache miss).
|
||||
func (c *Client) ResolveSession(ctx context.Context, token string) (string, error) {
|
||||
var out struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
|
||||
map[string]string{"token": token}, &out)
|
||||
return out.UserID, err
|
||||
}
|
||||
|
||||
// Profile returns the authenticated account's profile.
|
||||
func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error) {
|
||||
var out ProfileResp
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/user/profile", userID, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SubmitPlay commits a placement on the player's turn.
|
||||
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []TileJSON) (MoveResultResp, error) {
|
||||
var out MoveResultResp
|
||||
body := map[string]any{"dir": dir, "tiles": tiles}
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/play", userID, "", body, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GameState returns the player's view of a game.
|
||||
func (c *Client) GameState(ctx context.Context, userID, gameID string) (StateResp, error) {
|
||||
var out StateResp
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/user/games/"+url.PathEscape(gameID)+"/state", userID, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Enqueue joins the auto-match pool for a variant.
|
||||
func (c *Client) Enqueue(ctx context.Context, userID, variant string) (MatchResp, error) {
|
||||
var out MatchResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/lobby/enqueue", userID, "",
|
||||
map[string]string{"variant": variant}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Poll reports whether the caller has been paired since queueing.
|
||||
func (c *Client) Poll(ctx context.Context, userID string) (MatchResp, error) {
|
||||
var out MatchResp
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/user/lobby/poll", userID, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ChatPost stores a chat message, forwarding the client IP for moderation.
|
||||
func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP string) (ChatResp, error) {
|
||||
var out ChatResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/chat", userID, clientIP,
|
||||
map[string]string{"body": body}, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package backendclient is the gateway's typed client for the backend: REST/JSON
|
||||
// for synchronous operations (injecting X-User-ID) and a gRPC subscription for
|
||||
// the live push stream. The response structs mirror the backend's JSON DTOs; the
|
||||
// transcode layer turns them into FlatBuffers for the client.
|
||||
package backendclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
pushv1 "scrabble/pkg/proto/push/v1"
|
||||
)
|
||||
|
||||
// Client calls the backend's REST API and opens its push gRPC stream.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
conn *grpc.ClientConn
|
||||
push pushv1.PushClient
|
||||
}
|
||||
|
||||
// New dials the backend push gRPC endpoint and prepares the REST client. The
|
||||
// backend lives on a trusted network segment, so the gRPC connection uses
|
||||
// insecure (plaintext) transport credentials (ARCHITECTURE.md §12).
|
||||
func New(httpURL, grpcAddr string, timeout time.Duration) (*Client, error) {
|
||||
conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("backendclient: dial push %s: %w", grpcAddr, err)
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(httpURL, "/"),
|
||||
http: &http.Client{Timeout: timeout},
|
||||
conn: conn,
|
||||
push: pushv1.NewPushClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases the gRPC connection.
|
||||
func (c *Client) Close() error { return c.conn.Close() }
|
||||
|
||||
// APIError carries a backend error response so the transcode layer can surface a
|
||||
// stable result code to the client.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("backend %d (%s): %s", e.Status, e.Code, e.Message)
|
||||
}
|
||||
|
||||
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
||||
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation). A non-2xx
|
||||
// response is returned as an *APIError carrying the backend error code.
|
||||
func (c *Client) do(ctx context.Context, method, path, userID, clientIP string, body, out any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backendclient: marshal request: %w", err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backendclient: new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if userID != "" {
|
||||
req.Header.Set("X-User-ID", userID)
|
||||
}
|
||||
if clientIP != "" {
|
||||
req.Header.Set("X-Forwarded-For", clientIP)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backendclient: %s %s: %w", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backendclient: read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return parseAPIError(resp.StatusCode, data)
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return fmt.Errorf("backendclient: decode response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseAPIError extracts the backend's {error:{code,message}} envelope.
|
||||
func parseAPIError(status int, data []byte) *APIError {
|
||||
var env struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &env); err == nil && env.Error.Code != "" {
|
||||
return &APIError{Status: status, Code: env.Error.Code, Message: env.Error.Message}
|
||||
}
|
||||
return &APIError{Status: status, Code: "backend_error", Message: strings.TrimSpace(string(data))}
|
||||
}
|
||||
|
||||
// SubscribePush opens the backend live-event stream.
|
||||
func (c *Client) SubscribePush(ctx context.Context, gatewayID string) (grpc.ServerStreamingClient[pushv1.Event], error) {
|
||||
return c.push.Subscribe(ctx, &pushv1.SubscribeRequest{GatewayId: gatewayID})
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Package config loads and validates the gateway's runtime configuration from
|
||||
// the process environment. Every variable is prefixed GATEWAY_.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds the gateway's runtime configuration.
|
||||
type Config struct {
|
||||
// HTTPAddr is the public Connect/h2c listener address (host:port).
|
||||
HTTPAddr string
|
||||
// AdminAddr is the admin reverse-proxy listener address. Admin is enabled only
|
||||
// when AdminUser and AdminPassword are also set.
|
||||
AdminAddr string
|
||||
// LogLevel is the zap log level: "debug", "info", "warn" or "error".
|
||||
LogLevel string
|
||||
// BackendHTTPURL is the base URL of the backend REST API (gateway -> backend).
|
||||
BackendHTTPURL string
|
||||
// BackendGRPCAddr is the backend push gRPC address the gateway subscribes to.
|
||||
BackendGRPCAddr string
|
||||
// BackendTimeout bounds a single backend REST call.
|
||||
BackendTimeout time.Duration
|
||||
// AdminUser and AdminPassword are the Basic-Auth credentials the gateway
|
||||
// checks before proxying admin traffic to the backend. Empty disables admin.
|
||||
AdminUser string
|
||||
AdminPassword string
|
||||
// TelegramBotToken is the secret used to validate Telegram initData HMACs.
|
||||
// Empty disables the telegram auth path.
|
||||
TelegramBotToken string
|
||||
// SessionTTL bounds how long a resolved session stays cached; SessionCacheMax
|
||||
// caps the number of cached sessions.
|
||||
SessionTTL time.Duration
|
||||
SessionCacheMax int
|
||||
// PushHeartbeatInterval is the idle keep-alive cadence on a client live stream.
|
||||
PushHeartbeatInterval time.Duration
|
||||
// RateLimit configures the in-memory anti-abuse limiter.
|
||||
RateLimit RateLimitConfig
|
||||
}
|
||||
|
||||
// RateLimitConfig holds the token-bucket limits per class. Public and admin are
|
||||
// keyed per client IP; the authenticated class is keyed per user id; the email
|
||||
// sub-limit guards the costly email-code path per IP.
|
||||
type RateLimitConfig struct {
|
||||
PublicPerMinute int
|
||||
PublicBurst int
|
||||
UserPerMinute int
|
||||
UserBurst int
|
||||
AdminPerMinute int
|
||||
AdminBurst int
|
||||
EmailPer10Min int
|
||||
EmailBurst int
|
||||
}
|
||||
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
const (
|
||||
defaultHTTPAddr = ":8081"
|
||||
defaultAdminAddr = ":8082"
|
||||
defaultLogLevel = "info"
|
||||
defaultBackendHTTPURL = "http://localhost:8080"
|
||||
defaultBackendGRPCAddr = "localhost:9090"
|
||||
defaultBackendTimeout = 5 * time.Second
|
||||
defaultSessionTTL = 10 * time.Minute
|
||||
defaultSessionCacheMax = 50000
|
||||
defaultPushHeartbeatInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
// DefaultRateLimit returns the built-in anti-abuse limits.
|
||||
func DefaultRateLimit() RateLimitConfig {
|
||||
return RateLimitConfig{
|
||||
PublicPerMinute: 30, PublicBurst: 10,
|
||||
UserPerMinute: 120, UserBurst: 40,
|
||||
AdminPerMinute: 60, AdminBurst: 20,
|
||||
EmailPer10Min: 5, EmailBurst: 2,
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the configuration from the environment, applies defaults, and
|
||||
// validates the result.
|
||||
func Load() (Config, error) {
|
||||
var err error
|
||||
c := Config{
|
||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||
AdminAddr: envOr("GATEWAY_ADMIN_ADDR", defaultAdminAddr),
|
||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
TelegramBotToken: os.Getenv("GATEWAY_TELEGRAM_BOT_TOKEN"),
|
||||
SessionCacheMax: defaultSessionCacheMax,
|
||||
RateLimit: DefaultRateLimit(),
|
||||
}
|
||||
if c.BackendTimeout, err = envDuration("GATEWAY_BACKEND_TIMEOUT", defaultBackendTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.SessionTTL, err = envDuration("GATEWAY_SESSION_TTL", defaultSessionTTL); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.SessionCacheMax, err = envInt("GATEWAY_SESSION_CACHE_MAX", defaultSessionCacheMax); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.PushHeartbeatInterval, err = envDuration("GATEWAY_PUSH_HEARTBEAT_INTERVAL", defaultPushHeartbeatInterval); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// AdminEnabled reports whether the admin proxy should be served (an address and
|
||||
// both Basic-Auth credentials are configured).
|
||||
func (c Config) AdminEnabled() bool {
|
||||
return c.AdminAddr != "" && c.AdminUser != "" && c.AdminPassword != ""
|
||||
}
|
||||
|
||||
// validate reports whether the configuration values are acceptable.
|
||||
func (c Config) validate() error {
|
||||
switch c.LogLevel {
|
||||
case "debug", "info", "warn", "error":
|
||||
default:
|
||||
return fmt.Errorf("config: invalid GATEWAY_LOG_LEVEL %q", c.LogLevel)
|
||||
}
|
||||
if c.HTTPAddr == "" {
|
||||
return fmt.Errorf("config: GATEWAY_HTTP_ADDR must not be empty")
|
||||
}
|
||||
if c.BackendHTTPURL == "" {
|
||||
return fmt.Errorf("config: GATEWAY_BACKEND_HTTP_URL must not be empty")
|
||||
}
|
||||
if c.BackendGRPCAddr == "" {
|
||||
return fmt.Errorf("config: GATEWAY_BACKEND_GRPC_ADDR must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// envOr returns the value of the environment variable named key, or fallback
|
||||
// when the variable is unset or empty.
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// envInt parses the environment variable named key as an int, returning fallback
|
||||
// when it is unset and an error when it is set but malformed.
|
||||
func envInt(key string, fallback int) (int, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("config: %s: %w", key, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// envDuration parses the environment variable named key as a Go duration,
|
||||
// returning fallback when it is unset and an error when it is set but malformed.
|
||||
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("config: %s: %w", key, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package connectsrv
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Edge-level error values wrapped in Connect status codes. Domain outcomes are
|
||||
// not here — they ride back in the ExecuteResponse result_code.
|
||||
var (
|
||||
errRateLimited = errors.New("rate limit exceeded")
|
||||
errInternal = errors.New("internal error")
|
||||
errMissingToken = errors.New("missing session token")
|
||||
errInvalidSession = errors.New("invalid or expired session")
|
||||
)
|
||||
|
||||
// errUnknownMessageType reports an unregistered message type.
|
||||
func errUnknownMessageType(msgType string) error {
|
||||
return fmt.Errorf("unknown message type %q", msgType)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Package connectsrv implements the public Connect edge service over h2c. Execute
|
||||
// rate-limits, authenticates (resolving the Authorization bearer token to a user
|
||||
// id for non-auth operations), and dispatches to the transcode registry; the
|
||||
// domain outcome is carried back in the ExecuteResponse result_code. Subscribe
|
||||
// bridges the gateway push hub to a client server-stream with a keep-alive
|
||||
// heartbeat.
|
||||
package connectsrv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
|
||||
"scrabble/gateway/internal/config"
|
||||
"scrabble/gateway/internal/push"
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
"scrabble/gateway/internal/session"
|
||||
"scrabble/gateway/internal/transcode"
|
||||
edgev1 "scrabble/gateway/proto/edge/v1"
|
||||
"scrabble/gateway/proto/edge/v1/edgev1connect"
|
||||
)
|
||||
|
||||
// heartbeatKind is the live-stream keep-alive event kind.
|
||||
const heartbeatKind = "heartbeat"
|
||||
|
||||
// Server implements edgev1connect.GatewayHandler.
|
||||
type Server struct {
|
||||
registry *transcode.Registry
|
||||
sessions *session.Cache
|
||||
limiter *ratelimit.Limiter
|
||||
hub *push.Hub
|
||||
heartbeat time.Duration
|
||||
log *zap.Logger
|
||||
|
||||
publicPolicy ratelimit.Policy
|
||||
userPolicy ratelimit.Policy
|
||||
emailPolicy ratelimit.Policy
|
||||
}
|
||||
|
||||
// Deps carries the Server's dependencies.
|
||||
type Deps struct {
|
||||
Registry *transcode.Registry
|
||||
Sessions *session.Cache
|
||||
Limiter *ratelimit.Limiter
|
||||
Hub *push.Hub
|
||||
RateLimit config.RateLimitConfig
|
||||
Heartbeat time.Duration
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewServer constructs the edge service.
|
||||
func NewServer(d Deps) *Server {
|
||||
log := d.Logger
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &Server{
|
||||
registry: d.Registry,
|
||||
sessions: d.Sessions,
|
||||
limiter: d.Limiter,
|
||||
hub: d.Hub,
|
||||
heartbeat: d.Heartbeat,
|
||||
log: log,
|
||||
publicPolicy: ratelimit.PerMinute(d.RateLimit.PublicPerMinute, d.RateLimit.PublicBurst),
|
||||
userPolicy: ratelimit.PerMinute(d.RateLimit.UserPerMinute, d.RateLimit.UserBurst),
|
||||
emailPolicy: ratelimit.Per(d.RateLimit.EmailPer10Min, 10*time.Minute, d.RateLimit.EmailBurst),
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPHandler returns the h2c-wrapped Connect handler ready to serve.
|
||||
func (s *Server) HTTPHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
path, h := edgev1connect.NewGatewayHandler(s)
|
||||
mux.Handle(path, h)
|
||||
return h2c.NewHandler(mux, &http2.Server{})
|
||||
}
|
||||
|
||||
// Execute runs one unary operation. Domain failures are returned in the envelope
|
||||
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
||||
// session, unknown type, internal) become Connect errors.
|
||||
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (*connect.Response[edgev1.ExecuteResponse], error) {
|
||||
msgType := req.Msg.GetMessageType()
|
||||
op, ok := s.registry.Lookup(msgType)
|
||||
if !ok {
|
||||
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
|
||||
}
|
||||
clientIP := peerIP(req.Peer().Addr, req.Header())
|
||||
|
||||
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
||||
if op.Auth {
|
||||
uid, err := s.resolve(ctx, req.Header())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.limiter.Allow("user:"+uid, s.userPolicy) {
|
||||
return nil, connect.NewError(connect.CodeResourceExhausted, errRateLimited)
|
||||
}
|
||||
tr.UserID = uid
|
||||
} else {
|
||||
if !s.limiter.Allow("ip:"+clientIP, s.publicPolicy) {
|
||||
return nil, connect.NewError(connect.CodeResourceExhausted, errRateLimited)
|
||||
}
|
||||
if op.Email && !s.limiter.Allow("email:"+clientIP, s.emailPolicy) {
|
||||
return nil, connect.NewError(connect.CodeResourceExhausted, errRateLimited)
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := op.Handler(ctx, tr)
|
||||
if err != nil {
|
||||
if code, domain := transcode.DomainCode(err); domain {
|
||||
return connect.NewResponse(&edgev1.ExecuteResponse{
|
||||
RequestId: req.Msg.GetRequestId(),
|
||||
ResultCode: code,
|
||||
}), nil
|
||||
}
|
||||
s.log.Error("execute failed", zap.String("message_type", msgType), zap.Error(err))
|
||||
return nil, connect.NewError(connect.CodeInternal, errInternal)
|
||||
}
|
||||
return connect.NewResponse(&edgev1.ExecuteResponse{
|
||||
RequestId: req.Msg.GetRequestId(),
|
||||
ResultCode: "ok",
|
||||
Payload: payload,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Subscribe streams the authenticated user's live events with a keep-alive
|
||||
// heartbeat until the client disconnects.
|
||||
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
|
||||
uid, err := s.resolve(ctx, req.Header())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.limiter.Allow("user:"+uid, s.userPolicy) {
|
||||
return connect.NewError(connect.CodeResourceExhausted, errRateLimited)
|
||||
}
|
||||
|
||||
events, cancel := s.hub.Subscribe(uid)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(s.heartbeat)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if err := stream.Send(&edgev1.Event{Kind: heartbeatKind}); err != nil {
|
||||
return err
|
||||
}
|
||||
case e, ok := <-events:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := stream.Send(&edgev1.Event{Kind: e.Kind, Payload: e.Payload, EventId: e.EventID}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolve extracts and resolves the Authorization bearer token to an account id,
|
||||
// returning a Connect Unauthenticated error when it is missing or unknown.
|
||||
func (s *Server) resolve(ctx context.Context, h http.Header) (string, error) {
|
||||
token := bearerToken(h.Get("Authorization"))
|
||||
if token == "" {
|
||||
return "", connect.NewError(connect.CodeUnauthenticated, errMissingToken)
|
||||
}
|
||||
uid, err := s.sessions.Resolve(ctx, token)
|
||||
if err != nil {
|
||||
return "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
// bearerToken extracts the token from an "Authorization: Bearer <token>" header,
|
||||
// tolerating a bare token for convenience.
|
||||
func bearerToken(header string) string {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "" {
|
||||
return ""
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(header, "Bearer "); ok {
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
return header
|
||||
}
|
||||
|
||||
// peerIP prefers the X-Forwarded-For client hop, falling back to the connection
|
||||
// peer address (host part).
|
||||
func peerIP(peerAddr string, h http.Header) string {
|
||||
if xff := h.Get("X-Forwarded-For"); xff != "" {
|
||||
if i := strings.IndexByte(xff, ','); i >= 0 {
|
||||
return strings.TrimSpace(xff[:i])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(peerAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return peerAddr
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package connectsrv_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/config"
|
||||
"scrabble/gateway/internal/connectsrv"
|
||||
"scrabble/gateway/internal/push"
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
"scrabble/gateway/internal/session"
|
||||
"scrabble/gateway/internal/transcode"
|
||||
edgev1 "scrabble/gateway/proto/edge/v1"
|
||||
"scrabble/gateway/proto/edge/v1/edgev1connect"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
|
||||
// client plus a cleanup func.
|
||||
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
|
||||
t.Helper()
|
||||
backendSrv := httptest.NewServer(backendHandler)
|
||||
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("backendclient: %v", err)
|
||||
}
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Registry: transcode.NewRegistry(backend, nil),
|
||||
Sessions: session.NewCache(backend, time.Minute, 100),
|
||||
Limiter: ratelimit.New(),
|
||||
Hub: push.NewHub(0),
|
||||
RateLimit: config.DefaultRateLimit(),
|
||||
Heartbeat: 15 * time.Second,
|
||||
})
|
||||
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
||||
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
||||
return client, func() {
|
||||
edgeSrv.Close()
|
||||
_ = backend.Close()
|
||||
backendSrv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteGuestAuthOK(t *testing.T) {
|
||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
resp, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{
|
||||
MessageType: transcode.MsgAuthGuest,
|
||||
RequestId: "req-1",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if resp.Msg.GetResultCode() != "ok" || resp.Msg.GetRequestId() != "req-1" {
|
||||
t.Fatalf("result = %q req_id = %q", resp.Msg.GetResultCode(), resp.Msg.GetRequestId())
|
||||
}
|
||||
sess := fb.GetRootAsSession(resp.Msg.GetPayload(), 0)
|
||||
if string(sess.Token()) != "tok" || !sess.IsGuest() {
|
||||
t.Fatalf("session decoded wrong: %q guest=%v", sess.Token(), sess.IsGuest())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteAuthedRequiresSession(t *testing.T) {
|
||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("backend must not be called without a session")
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{
|
||||
MessageType: transcode.MsgProfileGet,
|
||||
}))
|
||||
if connect.CodeOf(err) != connect.CodeUnauthenticated {
|
||||
t.Fatalf("code = %v, want Unauthenticated", connect.CodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteUnknownMessageType(t *testing.T) {
|
||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{
|
||||
MessageType: "does.not.exist",
|
||||
}))
|
||||
if connect.CodeOf(err) != connect.CodeNotFound {
|
||||
t.Fatalf("code = %v, want NotFound", connect.CodeOf(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Package push is the gateway's live-event fan-out. The gateway holds one
|
||||
// backend gRPC subscription that feeds Publish; each connected client opens a
|
||||
// Subscribe stream and receives only the events addressed to its user id. A slow
|
||||
// client never blocks the backend feed — its bounded queue drops on overflow.
|
||||
package push
|
||||
|
||||
import "sync"
|
||||
|
||||
// Event is one live event addressed to a user. Payload is the FlatBuffers body
|
||||
// the gateway forwards verbatim to the client.
|
||||
type Event struct {
|
||||
UserID string
|
||||
Kind string
|
||||
Payload []byte
|
||||
EventID string
|
||||
}
|
||||
|
||||
// defaultBuffer is the per-client queue depth used when NewHub is given a
|
||||
// non-positive size.
|
||||
const defaultBuffer = 64
|
||||
|
||||
// Hub fans backend events out to per-user client subscriptions.
|
||||
type Hub struct {
|
||||
bufSize int
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
subs map[int]*subscription
|
||||
}
|
||||
|
||||
type subscription struct {
|
||||
userID string
|
||||
ch chan Event
|
||||
}
|
||||
|
||||
// NewHub constructs a Hub whose per-client queue holds bufSize events.
|
||||
func NewHub(bufSize int) *Hub {
|
||||
if bufSize <= 0 {
|
||||
bufSize = defaultBuffer
|
||||
}
|
||||
return &Hub{bufSize: bufSize, subs: make(map[int]*subscription)}
|
||||
}
|
||||
|
||||
// Publish delivers e to every subscription for e.UserID, dropping it for any
|
||||
// whose queue is full.
|
||||
func (h *Hub) Publish(e Event) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, s := range h.subs {
|
||||
if s.userID != e.UserID {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case s.ch <- e:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a client stream for userID and returns its event channel
|
||||
// and an unsubscribe func that closes the channel.
|
||||
func (h *Hub) Subscribe(userID string) (<-chan Event, func()) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
id := h.nextID
|
||||
h.nextID++
|
||||
s := &subscription{userID: userID, ch: make(chan Event, h.bufSize)}
|
||||
h.subs[id] = s
|
||||
return s.ch, func() { h.unsubscribe(id) }
|
||||
}
|
||||
|
||||
// unsubscribe removes and closes a subscription. It holds the same lock as
|
||||
// Publish, so it never closes a channel mid-send.
|
||||
func (h *Hub) unsubscribe(id int) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if s, ok := h.subs[id]; ok {
|
||||
delete(h.subs, id)
|
||||
close(s.ch)
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriberCount returns the number of active subscriptions (for tests/metrics).
|
||||
func (h *Hub) SubscriberCount() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.subs)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package push_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"scrabble/gateway/internal/push"
|
||||
)
|
||||
|
||||
func TestHubRoutesByUser(t *testing.T) {
|
||||
h := push.NewHub(4)
|
||||
chA, cancelA := h.Subscribe("user-a")
|
||||
defer cancelA()
|
||||
chB, cancelB := h.Subscribe("user-b")
|
||||
defer cancelB()
|
||||
|
||||
h.Publish(push.Event{UserID: "user-a", Kind: "your_turn"})
|
||||
|
||||
select {
|
||||
case e := <-chA:
|
||||
if e.Kind != "your_turn" {
|
||||
t.Fatalf("user-a received %q", e.Kind)
|
||||
}
|
||||
default:
|
||||
t.Fatal("user-a should have received the event")
|
||||
}
|
||||
select {
|
||||
case <-chB:
|
||||
t.Fatal("user-b must not receive user-a's event")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubDropsOnOverflow(t *testing.T) {
|
||||
h := push.NewHub(1)
|
||||
ch, cancel := h.Subscribe("u")
|
||||
defer cancel()
|
||||
for i := 0; i < 5; i++ {
|
||||
h.Publish(push.Event{UserID: "u", Kind: "chat_message"})
|
||||
}
|
||||
if got := len(ch); got != 1 {
|
||||
t.Fatalf("buffered %d events, want 1 (overflow dropped)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubUnsubscribeClosesChannel(t *testing.T) {
|
||||
h := push.NewHub(2)
|
||||
ch, cancel := h.Subscribe("u")
|
||||
cancel()
|
||||
if _, ok := <-ch; ok {
|
||||
t.Fatal("channel should be closed after unsubscribe")
|
||||
}
|
||||
if h.SubscriberCount() != 0 {
|
||||
t.Fatalf("subscriber count = %d, want 0", h.SubscriberCount())
|
||||
}
|
||||
h.Publish(push.Event{UserID: "u"}) // must not panic
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package ratelimit is the gateway's in-memory anti-abuse limiter: a token
|
||||
// bucket per key (golang.org/x/time/rate). The connect edge keys the public
|
||||
// class per client IP, the authenticated class per user id, and a stricter
|
||||
// sub-limit guards the email-code path; the admin proxy keys per IP. Buckets are
|
||||
// swept lazily so an idle key does not leak memory.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Policy is a token-bucket rate and burst.
|
||||
type Policy struct {
|
||||
Limit rate.Limit
|
||||
Burst int
|
||||
}
|
||||
|
||||
// PerMinute builds a Policy allowing perMinute events per minute with the given
|
||||
// burst.
|
||||
func PerMinute(perMinute, burst int) Policy {
|
||||
return Policy{Limit: rate.Limit(float64(perMinute) / 60.0), Burst: burst}
|
||||
}
|
||||
|
||||
// Per builds a Policy allowing events per window with the given burst.
|
||||
func Per(events int, window time.Duration, burst int) Policy {
|
||||
return Policy{Limit: rate.Limit(float64(events) / window.Seconds()), Burst: burst}
|
||||
}
|
||||
|
||||
// staleAfter is how long an unused bucket is retained before the lazy sweep
|
||||
// discards it; sweepInterval bounds how often the sweep runs.
|
||||
const (
|
||||
staleAfter = 10 * time.Minute
|
||||
sweepInterval = time.Minute
|
||||
)
|
||||
|
||||
// Limiter holds the per-key token buckets.
|
||||
type Limiter struct {
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
lim *rate.Limiter
|
||||
seen time.Time
|
||||
}
|
||||
|
||||
// New constructs an empty Limiter.
|
||||
func New() *Limiter {
|
||||
now := func() time.Time { return time.Now() }
|
||||
return &Limiter{now: now, buckets: make(map[string]*bucket), lastSweep: now()}
|
||||
}
|
||||
|
||||
// Allow reports whether one event under key is permitted by policy, consuming a
|
||||
// token when it is.
|
||||
func (l *Limiter) Allow(key string, p Policy) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
l.sweepLocked(now)
|
||||
b, ok := l.buckets[key]
|
||||
if !ok {
|
||||
b = &bucket{lim: rate.NewLimiter(p.Limit, p.Burst)}
|
||||
l.buckets[key] = b
|
||||
}
|
||||
b.seen = now
|
||||
return b.lim.Allow()
|
||||
}
|
||||
|
||||
// sweepLocked discards buckets unused for staleAfter, at most once per
|
||||
// sweepInterval. The caller holds l.mu.
|
||||
func (l *Limiter) sweepLocked(now time.Time) {
|
||||
if now.Sub(l.lastSweep) < sweepInterval {
|
||||
return
|
||||
}
|
||||
l.lastSweep = now
|
||||
for k, b := range l.buckets {
|
||||
if now.Sub(b.seen) > staleAfter {
|
||||
delete(l.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package ratelimit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
)
|
||||
|
||||
func TestAllowEnforcesBurst(t *testing.T) {
|
||||
l := ratelimit.New()
|
||||
p := ratelimit.PerMinute(60, 3) // 1/s, burst 3
|
||||
allowed := 0
|
||||
for i := 0; i < 5; i++ {
|
||||
if l.Allow("ip:1.2.3.4", p) {
|
||||
allowed++
|
||||
}
|
||||
}
|
||||
if allowed != 3 {
|
||||
t.Fatalf("allowed %d of 5, want 3 (burst)", allowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowIsolatesKeys(t *testing.T) {
|
||||
l := ratelimit.New()
|
||||
p := ratelimit.PerMinute(60, 1)
|
||||
if !l.Allow("user:a", p) {
|
||||
t.Fatal("first key should be allowed")
|
||||
}
|
||||
if !l.Allow("user:b", p) {
|
||||
t.Fatal("a different key must have its own bucket")
|
||||
}
|
||||
if l.Allow("user:a", p) {
|
||||
t.Fatal("the first key's bucket should now be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerWindow(t *testing.T) {
|
||||
// 5 events per 10 minutes, burst 2: the third immediate call is denied.
|
||||
p := ratelimit.Per(5, 10*time.Minute, 2)
|
||||
l := ratelimit.New()
|
||||
got := []bool{l.Allow("email:x", p), l.Allow("email:x", p), l.Allow("email:x", p)}
|
||||
if !got[0] || !got[1] || got[2] {
|
||||
t.Fatalf("per-window burst = %v, want [true true false]", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Package session is the gateway's in-memory session cache. It maps an opaque
|
||||
// bearer token to the backend account id, falling back to the backend's resolve
|
||||
// endpoint on a miss and caching the result for a bounded TTL. The backend
|
||||
// remains the source of truth (sessions are revoke-only there); the cache only
|
||||
// shortcuts the hot path.
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Resolver resolves a token to an account id at the backend (the cache miss
|
||||
// path). backendclient.Client satisfies it.
|
||||
type Resolver interface {
|
||||
ResolveSession(ctx context.Context, token string) (string, error)
|
||||
}
|
||||
|
||||
// Cache resolves session tokens to account ids, caching hits for ttl.
|
||||
type Cache struct {
|
||||
backend Resolver
|
||||
ttl time.Duration
|
||||
max int
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
entries map[string]entry
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
userID string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// NewCache constructs a Cache over backend with the given TTL and maximum size.
|
||||
func NewCache(backend Resolver, ttl time.Duration, max int) *Cache {
|
||||
if max <= 0 {
|
||||
max = 1
|
||||
}
|
||||
return &Cache{
|
||||
backend: backend,
|
||||
ttl: ttl,
|
||||
max: max,
|
||||
now: func() time.Time { return time.Now() },
|
||||
entries: make(map[string]entry),
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve returns the account id for token, consulting the cache first and the
|
||||
// backend on a miss (caching the result). An empty token is rejected by the
|
||||
// backend like any unknown token.
|
||||
func (c *Cache) Resolve(ctx context.Context, token string) (string, error) {
|
||||
if uid, ok := c.lookup(token); ok {
|
||||
return uid, nil
|
||||
}
|
||||
uid, err := c.backend.ResolveSession(ctx, token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.store(token, uid)
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
// Invalidate drops a token from the cache (e.g. after a revoke).
|
||||
func (c *Cache) Invalidate(token string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.entries, token)
|
||||
}
|
||||
|
||||
// lookup returns a live cached account id for token.
|
||||
func (c *Cache) lookup(token string) (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
e, ok := c.entries[token]
|
||||
if !ok || !c.now().Before(e.expires) {
|
||||
return "", false
|
||||
}
|
||||
return e.userID, true
|
||||
}
|
||||
|
||||
// store caches token -> userID, sweeping expired entries and bounding the size.
|
||||
func (c *Cache) store(token, userID string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if len(c.entries) >= c.max {
|
||||
c.evictLocked()
|
||||
}
|
||||
c.entries[token] = entry{userID: userID, expires: c.now().Add(c.ttl)}
|
||||
}
|
||||
|
||||
// evictLocked removes expired entries and, if still at capacity, drops arbitrary
|
||||
// entries until below the limit. The caller holds c.mu.
|
||||
func (c *Cache) evictLocked() {
|
||||
now := c.now()
|
||||
for k, e := range c.entries {
|
||||
if !now.Before(e.expires) {
|
||||
delete(c.entries, k)
|
||||
}
|
||||
}
|
||||
for k := range c.entries {
|
||||
if len(c.entries) < c.max {
|
||||
break
|
||||
}
|
||||
delete(c.entries, k)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeResolver struct {
|
||||
uid string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
return f.uid, nil
|
||||
}
|
||||
|
||||
func TestResolveCachesBackendHit(t *testing.T) {
|
||||
r := &fakeResolver{uid: "user-1"}
|
||||
c := NewCache(r, time.Minute, 10)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
uid, err := c.Resolve(context.Background(), "tok")
|
||||
if err != nil || uid != "user-1" {
|
||||
t.Fatalf("resolve #%d = (%q, %v)", i, uid, err)
|
||||
}
|
||||
}
|
||||
if r.calls != 1 {
|
||||
t.Fatalf("backend calls = %d, want 1 (cached)", r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePropagatesBackendError(t *testing.T) {
|
||||
r := &fakeResolver{err: errors.New("nope")}
|
||||
c := NewCache(r, time.Minute, 10)
|
||||
if _, err := c.Resolve(context.Background(), "tok"); err == nil {
|
||||
t.Fatal("expected backend error to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReResolvesAfterTTL(t *testing.T) {
|
||||
r := &fakeResolver{uid: "user-1"}
|
||||
c := NewCache(r, time.Minute, 10)
|
||||
base := time.Now()
|
||||
c.now = func() time.Time { return base }
|
||||
|
||||
if _, err := c.Resolve(context.Background(), "tok"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.now = func() time.Time { return base.Add(2 * time.Minute) } // past TTL
|
||||
if _, err := c.Resolve(context.Background(), "tok"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.calls != 2 {
|
||||
t.Fatalf("backend calls = %d, want 2 (re-resolve after expiry)", r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateForcesReResolve(t *testing.T) {
|
||||
r := &fakeResolver{uid: "user-1"}
|
||||
c := NewCache(r, time.Minute, 10)
|
||||
_, _ = c.Resolve(context.Background(), "tok")
|
||||
c.Invalidate("tok")
|
||||
_, _ = c.Resolve(context.Background(), "tok")
|
||||
if r.calls != 2 {
|
||||
t.Fatalf("backend calls = %d, want 2 after invalidate", r.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package transcode
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// The encoders build the FlatBuffers response payloads from the backend's typed
|
||||
// responses. FlatBuffers is built bottom-up: every string and child vector is
|
||||
// created before the table that references it, and no two tables/vectors are
|
||||
// under construction at once.
|
||||
|
||||
// encodeSession builds a Session payload.
|
||||
func encodeSession(s backendclient.SessionResp) []byte {
|
||||
b := flatbuffers.NewBuilder(128)
|
||||
token := b.CreateString(s.Token)
|
||||
uid := b.CreateString(s.UserID)
|
||||
name := b.CreateString(s.DisplayName)
|
||||
fb.SessionStart(b)
|
||||
fb.SessionAddToken(b, token)
|
||||
fb.SessionAddUserId(b, uid)
|
||||
fb.SessionAddIsGuest(b, s.IsGuest)
|
||||
fb.SessionAddDisplayName(b, name)
|
||||
b.Finish(fb.SessionEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeAck builds an Ack payload.
|
||||
func encodeAck(ok bool) []byte {
|
||||
b := flatbuffers.NewBuilder(16)
|
||||
fb.AckStart(b)
|
||||
fb.AckAddOk(b, ok)
|
||||
b.Finish(fb.AckEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeProfile builds a Profile payload.
|
||||
func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||
b := flatbuffers.NewBuilder(192)
|
||||
uid := b.CreateString(p.UserID)
|
||||
name := b.CreateString(p.DisplayName)
|
||||
lang := b.CreateString(p.PreferredLanguage)
|
||||
tz := b.CreateString(p.TimeZone)
|
||||
fb.ProfileStart(b)
|
||||
fb.ProfileAddUserId(b, uid)
|
||||
fb.ProfileAddDisplayName(b, name)
|
||||
fb.ProfileAddPreferredLanguage(b, lang)
|
||||
fb.ProfileAddTimeZone(b, tz)
|
||||
fb.ProfileAddHintBalance(b, int32(p.HintBalance))
|
||||
fb.ProfileAddBlockChat(b, p.BlockChat)
|
||||
fb.ProfileAddBlockFriendRequests(b, p.BlockFriendRequests)
|
||||
fb.ProfileAddIsGuest(b, p.IsGuest)
|
||||
b.Finish(fb.ProfileEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeMoveResult builds a MoveResult payload.
|
||||
func encodeMoveResult(r backendclient.MoveResultResp) []byte {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
move := buildMoveRecord(b, r.Move)
|
||||
game := buildGameView(b, r.Game)
|
||||
fb.MoveResultStart(b)
|
||||
fb.MoveResultAddMove(b, move)
|
||||
fb.MoveResultAddGame(b, game)
|
||||
b.Finish(fb.MoveResultEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeState builds a StateView payload.
|
||||
func encodeState(s backendclient.StateResp) []byte {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
game := buildGameView(b, s.Game)
|
||||
rack := buildStringVector(b, s.Rack, fb.StateViewStartRackVector)
|
||||
fb.StateViewStart(b)
|
||||
fb.StateViewAddGame(b, game)
|
||||
fb.StateViewAddSeat(b, int32(s.Seat))
|
||||
fb.StateViewAddRack(b, rack)
|
||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||
b.Finish(fb.StateViewEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeMatch builds a MatchResult payload.
|
||||
func encodeMatch(m backendclient.MatchResp) []byte {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
matched := m.Matched && m.Game != nil
|
||||
var game flatbuffers.UOffsetT
|
||||
if matched {
|
||||
game = buildGameView(b, *m.Game)
|
||||
}
|
||||
fb.MatchResultStart(b)
|
||||
fb.MatchResultAddMatched(b, matched)
|
||||
if matched {
|
||||
fb.MatchResultAddGame(b, game)
|
||||
}
|
||||
b.Finish(fb.MatchResultEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeChat builds a ChatMessage payload.
|
||||
func encodeChat(c backendclient.ChatResp) []byte {
|
||||
b := flatbuffers.NewBuilder(192)
|
||||
id := b.CreateString(c.ID)
|
||||
gid := b.CreateString(c.GameID)
|
||||
sid := b.CreateString(c.SenderID)
|
||||
kind := b.CreateString(c.Kind)
|
||||
body := b.CreateString(c.Body)
|
||||
fb.ChatMessageStart(b)
|
||||
fb.ChatMessageAddId(b, id)
|
||||
fb.ChatMessageAddGameId(b, gid)
|
||||
fb.ChatMessageAddSenderId(b, sid)
|
||||
fb.ChatMessageAddKind(b, kind)
|
||||
fb.ChatMessageAddBody(b, body)
|
||||
fb.ChatMessageAddCreatedAtUnix(b, c.CreatedAtUnix)
|
||||
b.Finish(fb.ChatMessageEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// buildGameView builds a GameView table and returns its offset.
|
||||
func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers.UOffsetT {
|
||||
seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats))
|
||||
for i, s := range g.Seats {
|
||||
aid := b.CreateString(s.AccountID)
|
||||
fb.SeatViewStart(b)
|
||||
fb.SeatViewAddSeat(b, int32(s.Seat))
|
||||
fb.SeatViewAddAccountId(b, aid)
|
||||
fb.SeatViewAddScore(b, int32(s.Score))
|
||||
fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed))
|
||||
fb.SeatViewAddIsWinner(b, s.IsWinner)
|
||||
seatOffs[i] = fb.SeatViewEnd(b)
|
||||
}
|
||||
fb.GameViewStartSeatsVector(b, len(seatOffs))
|
||||
for i := len(seatOffs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(seatOffs[i])
|
||||
}
|
||||
seats := b.EndVector(len(seatOffs))
|
||||
|
||||
id := b.CreateString(g.ID)
|
||||
variant := b.CreateString(g.Variant)
|
||||
dictVer := b.CreateString(g.DictVersion)
|
||||
status := b.CreateString(g.Status)
|
||||
endReason := b.CreateString(g.EndReason)
|
||||
|
||||
fb.GameViewStart(b)
|
||||
fb.GameViewAddId(b, id)
|
||||
fb.GameViewAddVariant(b, variant)
|
||||
fb.GameViewAddDictVersion(b, dictVer)
|
||||
fb.GameViewAddStatus(b, status)
|
||||
fb.GameViewAddPlayers(b, int32(g.Players))
|
||||
fb.GameViewAddToMove(b, int32(g.ToMove))
|
||||
fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs))
|
||||
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
|
||||
fb.GameViewAddEndReason(b, endReason)
|
||||
fb.GameViewAddSeats(b, seats)
|
||||
return fb.GameViewEnd(b)
|
||||
}
|
||||
|
||||
// buildMoveRecord builds a MoveRecord table and returns its offset.
|
||||
func buildMoveRecord(b *flatbuffers.Builder, m backendclient.MoveRecordResp) flatbuffers.UOffsetT {
|
||||
tileOffs := make([]flatbuffers.UOffsetT, len(m.Tiles))
|
||||
for i, t := range m.Tiles {
|
||||
letter := b.CreateString(t.Letter)
|
||||
fb.TileRecordStart(b)
|
||||
fb.TileRecordAddRow(b, int32(t.Row))
|
||||
fb.TileRecordAddCol(b, int32(t.Col))
|
||||
fb.TileRecordAddLetter(b, letter)
|
||||
fb.TileRecordAddBlank(b, t.Blank)
|
||||
tileOffs[i] = fb.TileRecordEnd(b)
|
||||
}
|
||||
fb.MoveRecordStartTilesVector(b, len(tileOffs))
|
||||
for i := len(tileOffs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(tileOffs[i])
|
||||
}
|
||||
tiles := b.EndVector(len(tileOffs))
|
||||
|
||||
words := buildStringVector(b, m.Words, fb.MoveRecordStartWordsVector)
|
||||
|
||||
action := b.CreateString(m.Action)
|
||||
dir := b.CreateString(m.Dir)
|
||||
fb.MoveRecordStart(b)
|
||||
fb.MoveRecordAddPlayer(b, int32(m.Player))
|
||||
fb.MoveRecordAddAction(b, action)
|
||||
fb.MoveRecordAddDir(b, dir)
|
||||
fb.MoveRecordAddMainRow(b, int32(m.MainRow))
|
||||
fb.MoveRecordAddMainCol(b, int32(m.MainCol))
|
||||
fb.MoveRecordAddTiles(b, tiles)
|
||||
fb.MoveRecordAddWords(b, words)
|
||||
fb.MoveRecordAddCount(b, int32(m.Count))
|
||||
fb.MoveRecordAddScore(b, int32(m.Score))
|
||||
fb.MoveRecordAddTotal(b, int32(m.Total))
|
||||
return fb.MoveRecordEnd(b)
|
||||
}
|
||||
|
||||
// buildStringVector builds a vector of strings using the table-specific
|
||||
// StartXVector function and returns the vector offset.
|
||||
func buildStringVector(b *flatbuffers.Builder, items []string, start func(*flatbuffers.Builder, int) flatbuffers.UOffsetT) flatbuffers.UOffsetT {
|
||||
offs := make([]flatbuffers.UOffsetT, len(items))
|
||||
for i, s := range items {
|
||||
offs[i] = b.CreateString(s)
|
||||
}
|
||||
start(b, len(offs))
|
||||
for i := len(offs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(offs[i])
|
||||
}
|
||||
return b.EndVector(len(offs))
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Package transcode is the gateway's FlatBuffers<->REST bridge. Each message type
|
||||
// maps to a handler that decodes the FlatBuffers request payload, calls the
|
||||
// backend over REST, and encodes the FlatBuffers response. The registry is the
|
||||
// authoritative message_type catalog; new operations are added here following the
|
||||
// same pattern (PLAN.md Stage 6 vertical slice).
|
||||
package transcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"scrabble/gateway/internal/auth"
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// Message types in the vertical slice.
|
||||
const (
|
||||
MsgAuthTelegram = "auth.telegram"
|
||||
MsgAuthGuest = "auth.guest"
|
||||
MsgAuthEmailReq = "auth.email.request"
|
||||
MsgAuthEmailLogin = "auth.email.login"
|
||||
MsgProfileGet = "profile.get"
|
||||
MsgGameSubmitPlay = "game.submit_play"
|
||||
MsgGameState = "game.state"
|
||||
MsgLobbyEnqueue = "lobby.enqueue"
|
||||
MsgLobbyPoll = "lobby.poll"
|
||||
MsgChatPost = "chat.post"
|
||||
)
|
||||
|
||||
// Request is one decoded Execute call.
|
||||
type Request struct {
|
||||
Payload []byte
|
||||
UserID string // resolved account id; empty for auth (unauthenticated) ops
|
||||
ClientIP string
|
||||
}
|
||||
|
||||
// Handler runs one operation and returns the FlatBuffers response payload.
|
||||
type Handler func(ctx context.Context, req Request) ([]byte, error)
|
||||
|
||||
// Op is a registered message type and its policy flags.
|
||||
type Op struct {
|
||||
Handler Handler
|
||||
// Auth marks an operation that requires a resolved session (X-User-ID).
|
||||
Auth bool
|
||||
// Email marks the costly email-code path that gets a stricter rate sub-limit.
|
||||
Email bool
|
||||
}
|
||||
|
||||
// Registry maps message types to their operations.
|
||||
type Registry struct {
|
||||
ops map[string]Op
|
||||
}
|
||||
|
||||
// NewRegistry builds the slice's message-type catalog over the backend client.
|
||||
// The Telegram auth op is registered only when a validator is supplied (a bot
|
||||
// token is configured); otherwise auth.telegram is simply unknown.
|
||||
func NewRegistry(backend *backendclient.Client, tg auth.TelegramValidator) *Registry {
|
||||
r := &Registry{ops: make(map[string]Op)}
|
||||
if tg != nil {
|
||||
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
|
||||
}
|
||||
r.ops[MsgAuthGuest] = Op{Handler: authGuestHandler(backend)}
|
||||
r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true}
|
||||
r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend), Email: true}
|
||||
r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true}
|
||||
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
|
||||
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
|
||||
r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true}
|
||||
r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(backend), Auth: true}
|
||||
r.ops[MsgChatPost] = Op{Handler: chatPostHandler(backend), Auth: true}
|
||||
return r
|
||||
}
|
||||
|
||||
// Lookup returns the operation for messageType, and whether it is registered.
|
||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||
op, ok := r.ops[messageType]
|
||||
return op, ok
|
||||
}
|
||||
|
||||
// DomainCode maps an error to a stable result code to surface in the Execute
|
||||
// envelope, reporting false for an unexpected error the caller should treat as a
|
||||
// transport-level internal failure.
|
||||
func DomainCode(err error) (string, bool) {
|
||||
var apiErr *backendclient.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.Code, true
|
||||
}
|
||||
if errors.Is(err, auth.ErrInvalidInitData) {
|
||||
return "invalid_init_data", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func authTelegramHandler(backend *backendclient.Client, tg auth.TelegramValidator) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0)
|
||||
user, err := tg.Validate(string(in.InitData()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess, err := backend.TelegramAuth(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeSession(sess), nil
|
||||
}
|
||||
}
|
||||
|
||||
func authGuestHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, _ Request) ([]byte, error) {
|
||||
sess, err := backend.GuestAuth(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeSession(sess), nil
|
||||
}
|
||||
}
|
||||
|
||||
func authEmailRequestHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsEmailRequestRequest(req.Payload, 0)
|
||||
if err := backend.EmailRequest(ctx, string(in.Email())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeAck(true), nil
|
||||
}
|
||||
}
|
||||
|
||||
func authEmailLoginHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsEmailLoginRequest(req.Payload, 0)
|
||||
sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeSession(sess), nil
|
||||
}
|
||||
}
|
||||
|
||||
func profileHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
p, err := backend.Profile(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeProfile(p), nil
|
||||
}
|
||||
}
|
||||
|
||||
func submitPlayHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsSubmitPlayRequest(req.Payload, 0)
|
||||
res, err := backend.SubmitPlay(ctx, req.UserID, string(in.GameId()), string(in.Dir()), decodeTiles(in))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeMoveResult(res), nil
|
||||
}
|
||||
}
|
||||
|
||||
func gameStateHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsStateRequest(req.Payload, 0)
|
||||
st, err := backend.GameState(ctx, req.UserID, string(in.GameId()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeState(st), nil
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsEnqueueRequest(req.Payload, 0)
|
||||
m, err := backend.Enqueue(ctx, req.UserID, string(in.Variant()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeMatch(m), nil
|
||||
}
|
||||
}
|
||||
|
||||
func pollHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
m, err := backend.Poll(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeMatch(m), nil
|
||||
}
|
||||
}
|
||||
|
||||
func chatPostHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsChatPostRequest(req.Payload, 0)
|
||||
c, err := backend.ChatPost(ctx, req.UserID, string(in.GameId()), string(in.Body()), req.ClientIP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeChat(c), nil
|
||||
}
|
||||
}
|
||||
|
||||
// decodeTiles reads the placed tiles from a SubmitPlayRequest.
|
||||
func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.TileJSON {
|
||||
n := in.TilesLength()
|
||||
tiles := make([]backendclient.TileJSON, 0, n)
|
||||
var t fb.TileRecord
|
||||
for i := 0; i < n; i++ {
|
||||
if in.Tiles(&t, i) {
|
||||
tiles = append(tiles, backendclient.TileJSON{
|
||||
Row: int(t.Row()),
|
||||
Col: int(t.Col()),
|
||||
Letter: string(t.Letter()),
|
||||
Blank: t.Blank(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package transcode_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/transcode"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// fakeBackend serves the subset of backend endpoints the slice handlers call.
|
||||
func fakeBackend(t *testing.T, h http.HandlerFunc) (*backendclient.Client, func()) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
c, err := backendclient.New(srv.URL, "localhost:9090", 2_000_000_000)
|
||||
if err != nil {
|
||||
t.Fatalf("backendclient: %v", err)
|
||||
}
|
||||
return c, func() {
|
||||
_ = c.Close()
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestAuthRoundTrip(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/internal/sessions/guest" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"token":"tok-1","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil)
|
||||
op, ok := reg.Lookup(transcode.MsgAuthGuest)
|
||||
if !ok {
|
||||
t.Fatal("auth.guest not registered")
|
||||
}
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
sess := fb.GetRootAsSession(payload, 0)
|
||||
if string(sess.Token()) != "tok-1" || string(sess.UserId()) != "u-1" || !sess.IsGuest() {
|
||||
t.Fatalf("session decoded wrong: token=%q user=%q guest=%v", sess.Token(), sess.UserId(), sess.IsGuest())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-User-ID"); got != "u-7" {
|
||||
t.Errorf("X-User-ID = %q, want u-7", got)
|
||||
}
|
||||
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"english","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":["A","B"],"bag_len":80,"hints_remaining":1}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil)
|
||||
op, _ := reg.Lookup(transcode.MsgGameState)
|
||||
|
||||
b := flatbuffers.NewBuilder(32)
|
||||
gid := b.CreateString("g-1")
|
||||
fb.StateRequestStart(b)
|
||||
fb.StateRequestAddGameId(b, gid)
|
||||
b.Finish(fb.StateRequestEnd(b))
|
||||
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-7"})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
st := fb.GetRootAsStateView(payload, 0)
|
||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 1 {
|
||||
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d", st.BagLen(), st.RackLength(), st.HintsRemaining())
|
||||
}
|
||||
game := st.Game(nil)
|
||||
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "english" || game.ToMove() != 1 {
|
||||
t.Fatalf("nested game decoded wrong: %+v", game)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueRoundTripEncodesMatch(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"matched":true,"game":{"id":"g-9","variant":"english","status":"active","players":2,"to_move":0,"seats":[]}}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil)
|
||||
op, _ := reg.Lookup(transcode.MsgLobbyEnqueue)
|
||||
|
||||
b := flatbuffers.NewBuilder(32)
|
||||
v := b.CreateString("english")
|
||||
fb.EnqueueRequestStart(b)
|
||||
fb.EnqueueRequestAddVariant(b, v)
|
||||
b.Finish(fb.EnqueueRequestEnd(b))
|
||||
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
m := fb.GetRootAsMatchResult(payload, 0)
|
||||
if !m.Matched() {
|
||||
t.Fatal("match result should be matched")
|
||||
}
|
||||
if g := m.Game(nil); g == nil || string(g.Id()) != "g-9" {
|
||||
t.Fatalf("match game decoded wrong: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainErrorSurfacesBackendCode(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"not_your_turn","message":"nope"}}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil)
|
||||
op, _ := reg.Lookup(transcode.MsgGameState)
|
||||
|
||||
b := flatbuffers.NewBuilder(32)
|
||||
gid := b.CreateString("g-1")
|
||||
fb.StateRequestStart(b)
|
||||
fb.StateRequestAddGameId(b, gid)
|
||||
b.Finish(fb.StateRequestEnd(b))
|
||||
|
||||
_, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected backend error")
|
||||
}
|
||||
code, ok := transcode.DomainCode(err)
|
||||
if !ok || code != "not_your_turn" {
|
||||
t.Fatalf("DomainCode = (%q, %v), want (not_your_turn, true)", code, ok)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user