R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN) references from comments, doc-comments, service READMEs, the current-state docs (ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the .fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage history. - Rename the only stage-named identifiers: registerStage8 -> registerSocialOps, registerStage11 -> registerLinkOps (gateway transcode). - Split stage6_test.go: TestEmailLoginFlow -> email_test.go, TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go. - Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments). go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
This commit is contained in:
@@ -35,7 +35,7 @@ type ProfileResp struct {
|
||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||
}
|
||||
|
||||
// LinkResultResp is the result of an account link/merge step (Stage 11). Status is
|
||||
// LinkResultResp is the result of an account link/merge step. Status is
|
||||
// "linked", "merge_required" (the secondary_* fields summarise the other account) or
|
||||
// "merged". Token is a switched-session token (a guest initiator's durable
|
||||
// counterpart won); Profile is the surviving/active account's profile.
|
||||
@@ -50,7 +50,7 @@ type LinkResultResp struct {
|
||||
}
|
||||
|
||||
// TileJSON is one tile in a decoded move response (history, move result, hint); its Letter
|
||||
// is a concrete character (Stage 13 keeps the move journal in letters).
|
||||
// is a concrete character (the move journal is kept in letters).
|
||||
type TileJSON struct {
|
||||
Row int `json:"row"`
|
||||
Col int `json:"col"`
|
||||
@@ -58,7 +58,7 @@ type TileJSON struct {
|
||||
Blank bool `json:"blank"`
|
||||
}
|
||||
|
||||
// PlayTileJSON is one inbound tile to place, addressed by alphabet index (Stage 13). For a
|
||||
// PlayTileJSON is one inbound tile to place, addressed by alphabet index. For a
|
||||
// blank, Letter is the designated letter's index and Blank is true.
|
||||
type PlayTileJSON struct {
|
||||
Row int `json:"row"`
|
||||
@@ -107,7 +107,7 @@ type GameResp struct {
|
||||
}
|
||||
|
||||
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
||||
// wire alphabet indices and BagLen the bag size after the draw (R4).
|
||||
// wire alphabet indices and BagLen the bag size after the draw.
|
||||
type MoveResultResp struct {
|
||||
Move MoveRecordResp `json:"move"`
|
||||
Game GameResp `json:"game"`
|
||||
@@ -116,14 +116,14 @@ type MoveResultResp struct {
|
||||
}
|
||||
|
||||
// AlphabetEntryJSON is one letter of a variant's alphabet (its index, concrete letter and
|
||||
// tile value), present in StateResp only when the client requested it (Stage 13).
|
||||
// tile value), present in StateResp only when the client requested it.
|
||||
type AlphabetEntryJSON struct {
|
||||
Index int `json:"index"`
|
||||
Letter string `json:"letter"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
// StateResp is a player's view of a game. Rack carries wire alphabet indices (Stage 13);
|
||||
// StateResp is a player's view of a game. Rack carries wire alphabet indices;
|
||||
// Alphabet is present only when the request asked for it.
|
||||
type StateResp struct {
|
||||
Game GameResp `json:"game"`
|
||||
@@ -224,7 +224,7 @@ func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error
|
||||
}
|
||||
|
||||
// SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet
|
||||
// index (Stage 13).
|
||||
// index.
|
||||
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (MoveResultResp, error) {
|
||||
var out MoveResultResp
|
||||
body := map[string]any{"dir": dir, "tiles": tiles}
|
||||
@@ -233,7 +233,7 @@ func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, til
|
||||
}
|
||||
|
||||
// GameState returns the player's view of a game. When includeAlphabet is set the backend
|
||||
// embeds the variant's alphabet table (Stage 13); the client asks for it on a per-variant
|
||||
// embeds the variant's alphabet table; the client asks for it on a per-variant
|
||||
// cache miss only.
|
||||
func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAlphabet bool) (StateResp, error) {
|
||||
var out StateResp
|
||||
@@ -320,7 +320,7 @@ func (c *Client) Pass(ctx context.Context, userID, gameID string) (MoveResultRes
|
||||
}
|
||||
|
||||
// Exchange swaps the chosen rack tiles back into the bag. Tiles are wire alphabet indices
|
||||
// (Stage 13; a blank is engine.BlankIndex).
|
||||
// (a blank is engine.BlankIndex).
|
||||
func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []int) (MoveResultResp, error) {
|
||||
var out MoveResultResp
|
||||
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/exchange"), userID, "",
|
||||
@@ -342,7 +342,7 @@ func (c *Client) Hint(ctx context.Context, userID, gameID string) (HintResultRes
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetDraft returns the player's saved composition for a game (Stage 17) as the backend's
|
||||
// GetDraft returns the player's saved composition for a game as the backend's
|
||||
// raw JSON body. The gateway forwards it verbatim, never interpreting its shape.
|
||||
func (c *Client) GetDraft(ctx context.Context, userID, gameID string) (json.RawMessage, error) {
|
||||
var out json.RawMessage
|
||||
@@ -350,21 +350,21 @@ func (c *Client) GetDraft(ctx context.Context, userID, gameID string) (json.RawM
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SaveDraft upserts the player's composition for a game (Stage 17). body is the client's
|
||||
// SaveDraft upserts the player's composition for a game. body is the client's
|
||||
// {rack_order, board_tiles} JSON, forwarded verbatim — a json.RawMessage marshals as-is, so
|
||||
// there is no double-encode.
|
||||
func (c *Client) SaveDraft(ctx context.Context, userID, gameID string, body json.RawMessage) error {
|
||||
return c.do(ctx, http.MethodPut, c.gamePath(gameID, "/draft"), userID, "", body, nil)
|
||||
}
|
||||
|
||||
// HideGame hides a finished game from the caller's own games list (Stage 17). The action is
|
||||
// HideGame hides a finished game from the caller's own games list. The action is
|
||||
// per-account and irreversible; the game stays visible to the other players.
|
||||
func (c *Client) HideGame(ctx context.Context, userID, gameID string) error {
|
||||
return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/hide"), userID, "", struct{}{}, nil)
|
||||
}
|
||||
|
||||
// Evaluate previews a tentative play's legality and score. The tiles are addressed by
|
||||
// alphabet index (Stage 13).
|
||||
// alphabet index.
|
||||
func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) {
|
||||
var out EvalResultResp
|
||||
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "",
|
||||
@@ -373,7 +373,7 @@ func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles
|
||||
}
|
||||
|
||||
// CheckWord looks a word up in the game's pinned dictionary. The word is carried as
|
||||
// repeated ?idx= alphabet indices (Stage 13); the backend echoes the decoded concrete word.
|
||||
// repeated ?idx= alphabet indices; the backend echoes the decoded concrete word.
|
||||
func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) {
|
||||
var out WordCheckResp
|
||||
q := url.Values{}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// The Stage 8 response structs and client methods mirror the backend's social,
|
||||
// The response structs and client methods mirror the backend's social,
|
||||
// account and history JSON DTOs. The transcode layer maps them to FlatBuffers.
|
||||
|
||||
// AccountRefResp is a referenced account with its display name resolved.
|
||||
@@ -249,7 +249,7 @@ func (c *Client) LinkEmailRequest(ctx context.Context, userID, email string) err
|
||||
}
|
||||
|
||||
// LinkEmailConfirm verifies the code and binds a free email or reports a required
|
||||
// merge (Stage 11).
|
||||
// merge.
|
||||
func (c *Client) LinkEmailConfirm(ctx context.Context, userID, email, code string) (LinkResultResp, error) {
|
||||
var out LinkResultResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/confirm", userID, "",
|
||||
@@ -257,7 +257,7 @@ func (c *Client) LinkEmailConfirm(ctx context.Context, userID, email, code strin
|
||||
return out, err
|
||||
}
|
||||
|
||||
// LinkEmailMerge re-verifies the code and performs the merge (Stage 11).
|
||||
// LinkEmailMerge re-verifies the code and performs the merge.
|
||||
func (c *Client) LinkEmailMerge(ctx context.Context, userID, email, code string) (LinkResultResp, error) {
|
||||
var out LinkResultResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/merge", userID, "",
|
||||
@@ -266,7 +266,7 @@ func (c *Client) LinkEmailMerge(ctx context.Context, userID, email, code string)
|
||||
}
|
||||
|
||||
// LinkTelegram attaches a gateway-validated Telegram identity to the caller or
|
||||
// reports a required merge (Stage 11).
|
||||
// reports a required merge.
|
||||
func (c *Client) LinkTelegram(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
|
||||
var out LinkResultResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/telegram", userID, "",
|
||||
@@ -275,7 +275,7 @@ func (c *Client) LinkTelegram(ctx context.Context, userID, externalID string) (L
|
||||
}
|
||||
|
||||
// LinkTelegramMerge merges the account owning a gateway-validated Telegram identity
|
||||
// into the caller's (Stage 11).
|
||||
// into the caller's.
|
||||
func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
|
||||
var out LinkResultResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/telegram/merge", userID, "",
|
||||
|
||||
@@ -129,7 +129,7 @@ func (c *Client) SubscribePush(ctx context.Context, gatewayID string) (grpc.Serv
|
||||
// ReportRateLimited posts the gateway's periodic rate-limiter rejection summary
|
||||
// to the backend, which feeds the admin console's throttled view and the
|
||||
// high-rate auto-flag. The endpoint carries no user identity: like
|
||||
// sessions/resolve it rides the trusted internal segment (R3).
|
||||
// sessions/resolve it rides the trusted internal segment.
|
||||
func (c *Client) ReportRateLimited(ctx context.Context, windowSeconds int, entries []ratelimit.Rejection) error {
|
||||
body := struct {
|
||||
WindowSeconds int `json:"window_seconds"`
|
||||
|
||||
@@ -76,13 +76,13 @@ const (
|
||||
defaultBackendTimeout = 5 * time.Second
|
||||
defaultSessionTTL = 10 * time.Minute
|
||||
defaultSessionCacheMax = 50000
|
||||
defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout (Stage 17)
|
||||
defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout
|
||||
defaultServiceName = "scrabble-gateway"
|
||||
)
|
||||
|
||||
// DefaultMaxBodyBytes is the default request-body cap (GATEWAY_MAX_BODY_BYTES):
|
||||
// 1 MiB — far above any legitimate edge payload (drafts and chat are a few KB)
|
||||
// yet small enough to stop a cheap memory-amplification upload (R3).
|
||||
// yet small enough to stop a cheap memory-amplification upload.
|
||||
const DefaultMaxBodyBytes = 1 << 20
|
||||
|
||||
// supportedLanguages is the set of game languages a service may declare for the
|
||||
@@ -98,8 +98,8 @@ func DefaultRateLimit() RateLimitConfig {
|
||||
PublicPerMinute: 30, PublicBurst: 10,
|
||||
// Per-user (not per-IP): one user may run several devices, each holding a
|
||||
// Subscribe stream and reloading state on every live event, so the authenticated
|
||||
// budget is generous (a per-user cap cannot DoS the service). Raised in Stage 17
|
||||
// after multi-device play tripped the old 120/40.
|
||||
// budget is generous (a per-user cap cannot DoS the service). It is raised
|
||||
// because multi-device play tripped the old 120/40.
|
||||
UserPerMinute: 300, UserBurst: 80,
|
||||
AdminPerMinute: 60, AdminBurst: 20,
|
||||
EmailPer10Min: 5, EmailBurst: 2,
|
||||
|
||||
@@ -84,7 +84,7 @@ func (c *Client) ValidateInitData(ctx context.Context, initData string) (User, e
|
||||
|
||||
// ValidateLoginWidget verifies Telegram Login Widget data and returns the user
|
||||
// identity, mapping a connector InvalidArgument to ErrInvalidLoginWidget. It backs
|
||||
// the link.telegram edge operation (Stage 11).
|
||||
// the link.telegram edge operation.
|
||||
func (c *Client) ValidateLoginWidget(ctx context.Context, data string) (User, error) {
|
||||
resp, err := c.c.ValidateLoginWidget(ctx, &telegramv1.ValidateLoginWidgetRequest{Data: data})
|
||||
if err != nil {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// TestPeerIP covers the client-IP extraction the chat-moderation IP and the per-IP rate
|
||||
// limiter both rely on: the first X-Forwarded-For hop (the real client, once Caddy is
|
||||
// configured to trust its upstream), falling back to the connection peer (Stage 17).
|
||||
// configured to trust its upstream), falling back to the connection peer.
|
||||
func TestPeerIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -35,7 +35,7 @@ import (
|
||||
const heartbeatKind = "heartbeat"
|
||||
|
||||
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
|
||||
// class field of the periodic rejection report (R3).
|
||||
// class field of the periodic rejection report.
|
||||
const (
|
||||
classUser = "user"
|
||||
classPublic = "public"
|
||||
@@ -43,13 +43,13 @@ const (
|
||||
classAdmin = "admin"
|
||||
)
|
||||
|
||||
// Explicit h2c server sizing (R3, after the R2 stress run questioned the
|
||||
// implicit defaults).
|
||||
// Explicit h2c server sizing, made explicit rather than relying on the
|
||||
// implicit defaults.
|
||||
const (
|
||||
// h2cMaxConcurrentStreams bounds the open streams per client connection — the
|
||||
// x/net default made explicit. A real client holds one Subscribe stream plus a
|
||||
// few unary calls; only a synthetic load multiplexing many players over one
|
||||
// transport approaches it. R7 revisits the sizing.
|
||||
// transport approaches it.
|
||||
h2cMaxConcurrentStreams = 250
|
||||
// h2cIdleTimeout closes a connection with no open streams. A live Subscribe
|
||||
// stream keeps its connection active, so long-lived clients are unaffected;
|
||||
@@ -151,7 +151,7 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// working over h2c (docs/ARCHITECTURE.md §12). In the deployed contour the
|
||||
// front caddy owns the /_gm Basic-Auth and Grafana routing; this mount serves
|
||||
// a non-caddy (local) setup. The per-IP admin limiter class guards it —
|
||||
// notably a Basic-Auth brute force (R3).
|
||||
// notably a Basic-Auth brute force.
|
||||
mux.Handle("/_gm/", s.limitAdmin(s.adminProxy))
|
||||
} else {
|
||||
// With the console disabled here, keep /_gm a 404 so the SPA catch-all below
|
||||
@@ -162,14 +162,14 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below
|
||||
// the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and
|
||||
// each mount falls back to the app shell (index.html) for the hash router. The
|
||||
// public landing moved to its own static container behind the contour caddy
|
||||
// (R3), so the catch-all redirects a stray root hit to the app shell — which
|
||||
// public landing lives in its own static container behind the contour caddy,
|
||||
// so the catch-all redirects a stray root hit to the app shell — which
|
||||
// keeps a local no-caddy run usable.
|
||||
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
||||
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
|
||||
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
|
||||
// Every request body on the public listener is capped (the admin proxy POSTs
|
||||
// included); the h2c server carries explicit stream/idle sizing (R3).
|
||||
// included); the h2c server carries explicit stream/idle sizing.
|
||||
return h2c.NewHandler(maxBodyHandler(s.maxBodyBytes, mux), &http2.Server{
|
||||
MaxConcurrentStreams: h2cMaxConcurrentStreams,
|
||||
IdleTimeout: h2cIdleTimeout,
|
||||
@@ -264,7 +264,7 @@ func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.Subs
|
||||
// Send an immediate heartbeat so the stream's first byte flushes through the proxy chain
|
||||
// right away and resets edge/client idle timers, instead of the connection sitting silent
|
||||
// until the first tick — which otherwise raced a ~15 s idle timeout and forced a reconnect
|
||||
// every interval (Stage 17).
|
||||
// every interval.
|
||||
if err := stream.Send(&edgev1.Event{Kind: heartbeatKind}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -294,7 +294,7 @@ func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.Subs
|
||||
// noteRateLimited accounts one limiter rejection: the aggregate counter, the
|
||||
// per-rejection Debug line and the periodic-report tracker. The operational
|
||||
// signal is the reporter's Warn summary; per-rejection logging stays at Debug so
|
||||
// a rejection flood cannot flood the log (R3).
|
||||
// a rejection flood cannot flood the log.
|
||||
func (s *Server) noteRateLimited(ctx context.Context, class, key, msgType string) {
|
||||
s.metrics.recordRateLimited(ctx, class)
|
||||
s.tracker.Add(class, key)
|
||||
@@ -315,7 +315,7 @@ func (s *Server) rejectRateLimited(ctx context.Context, class, key, msgType stri
|
||||
// of its Basic-Auth check (a credential brute force is exactly what it bounds).
|
||||
// It covers the gateway-fronted /_gm mount; in the deployed contour /_gm reaches
|
||||
// the backend through caddy, whose Basic-Auth has no limiter (stock caddy) — see
|
||||
// docs/ARCHITECTURE.md §12 (R3).
|
||||
// docs/ARCHITECTURE.md §12.
|
||||
func (s *Server) limitAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := peerIP(r.RemoteAddr, r.Header)
|
||||
@@ -340,8 +340,8 @@ func (s *Server) resolve(ctx context.Context, h http.Header) (string, error) {
|
||||
// An unknown or expired token (a backend 4xx) is the client's problem and
|
||||
// stays silent; anything else — a resolve timeout, a refused connection, a
|
||||
// backend 5xx — is an infra failure misread as "unauthenticated" by the
|
||||
// client, so surface the cause (the transient resolves seen under load in
|
||||
// the R2 stress run). The token itself is never logged.
|
||||
// client, so surface the cause (the transient resolves seen under load).
|
||||
// The token itself is never logged.
|
||||
var apiErr *backendclient.APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError {
|
||||
s.log.Warn("session resolve failed", zap.Error(err))
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestExecuteAuthedRequiresSession(t *testing.T) {
|
||||
|
||||
// TestExecuteRateLimitedTracked verifies a limiter rejection returns
|
||||
// ResourceExhausted and lands in the rejection tracker under the public class,
|
||||
// keyed by the client IP (R3).
|
||||
// keyed by the client IP.
|
||||
func TestExecuteRateLimitedTracked(t *testing.T) {
|
||||
backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
||||
@@ -135,7 +135,7 @@ func TestExecuteRateLimitedTracked(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAdminMountRateLimited verifies the /_gm mount is guarded by the per-IP
|
||||
// admin limiter class ahead of the proxy's Basic-Auth (R3).
|
||||
// admin limiter class ahead of the proxy's Basic-Auth.
|
||||
func TestAdminMountRateLimited(t *testing.T) {
|
||||
backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer backendSrv.Close()
|
||||
@@ -181,7 +181,7 @@ func TestAdminMountRateLimited(t *testing.T) {
|
||||
|
||||
// TestExecuteOversizedPayloadRejected verifies the request-body cap: an Execute
|
||||
// message above GATEWAY_MAX_BODY_BYTES is refused at the edge without reaching
|
||||
// the backend (R3).
|
||||
// the backend.
|
||||
func TestExecuteOversizedPayloadRejected(t *testing.T) {
|
||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("backend must not be called for an oversized payload")
|
||||
@@ -198,7 +198,7 @@ func TestExecuteOversizedPayloadRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestRootRedirectsToApp verifies the gateway no longer serves a landing at "/"
|
||||
// (it lives in the landing container since R3): a stray root hit is redirected
|
||||
// (it lives in the landing container): a stray root hit is redirected
|
||||
// to the app shell.
|
||||
func TestRootRedirectsToApp(t *testing.T) {
|
||||
front := httptest.NewServer(connectsrv.NewServer(connectsrv.Deps{}).HTTPHandler())
|
||||
|
||||
@@ -80,7 +80,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeLinkResult builds a LinkResult payload (Stage 11). A switched-session token
|
||||
// encodeLinkResult builds a LinkResult payload. A switched-session token
|
||||
// (a guest initiator whose durable counterpart won) is carried as a nested Session
|
||||
// for the client to adopt; it is omitted otherwise. supportedLangs is the variant
|
||||
// gating set for that switched session — the link flows run on the web, so it is the
|
||||
@@ -138,7 +138,7 @@ func encodeMoveResult(r backendclient.MoveResultResp) []byte {
|
||||
}
|
||||
|
||||
// encodeState builds a StateView payload. The rack is a vector of alphabet indices and the
|
||||
// alphabet display table is included only when the backend returned it (Stage 13: the
|
||||
// alphabet display table is included only when the backend returned it (the
|
||||
// client requests it on a per-variant cache miss).
|
||||
func encodeState(s backendclient.StateResp) []byte {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// Stage 8 encoders: friends, blocks, invitations, statistics and GCG. They follow
|
||||
// Social encoders: friends, blocks, invitations, statistics and GCG. They follow
|
||||
// encode.go's bottom-up rule (build every string/child vector before the table).
|
||||
|
||||
// buildAccountRef builds an AccountRef table and returns its offset.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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).
|
||||
// same vertical-slice pattern.
|
||||
package transcode
|
||||
|
||||
import (
|
||||
@@ -69,7 +69,7 @@ type Registry struct {
|
||||
}
|
||||
|
||||
// TelegramValidator validates Telegram credentials via the connector side-service:
|
||||
// Mini App launch data (auth) and Login Widget data (linking, Stage 11).
|
||||
// Mini App launch data (auth) and Login Widget data (linking).
|
||||
// *connector.Client implements it; a nil value disables the telegram auth and
|
||||
// telegram-link paths.
|
||||
type TelegramValidator interface {
|
||||
@@ -115,8 +115,8 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan
|
||||
r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true}
|
||||
r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true}
|
||||
r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true}
|
||||
registerStage8(r, backend)
|
||||
registerStage11(r, backend, tg, defaultLanguages)
|
||||
registerSocialOps(r, backend)
|
||||
registerLinkOps(r, backend, tg, defaultLanguages)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ func chatPostHandler(backend *backendclient.Client) Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// decodeTiles reads the index-addressed tiles to place from a SubmitPlayRequest (Stage 13).
|
||||
// decodeTiles reads the index-addressed tiles to place from a SubmitPlayRequest.
|
||||
func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.PlayTileJSON {
|
||||
n := in.TilesLength()
|
||||
tiles := make([]backendclient.PlayTileJSON, 0, n)
|
||||
@@ -282,7 +282,7 @@ func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.PlayTileJSON {
|
||||
return tiles
|
||||
}
|
||||
|
||||
// decodeEvalTiles reads the index-addressed tentative tiles from an EvalRequest (Stage 13).
|
||||
// decodeEvalTiles reads the index-addressed tentative tiles from an EvalRequest.
|
||||
func decodeEvalTiles(in *fb.EvalRequest) []backendclient.PlayTileJSON {
|
||||
n := in.TilesLength()
|
||||
tiles := make([]backendclient.PlayTileJSON, 0, n)
|
||||
@@ -301,7 +301,7 @@ func decodeEvalTiles(in *fb.EvalRequest) []backendclient.PlayTileJSON {
|
||||
}
|
||||
|
||||
// bytesToInts widens a FlatBuffers ubyte vector (an alphabet-index list) to []int for the
|
||||
// backend JSON edge (Stage 13: rack-exchange tiles and the word-check query).
|
||||
// backend JSON edge (rack-exchange tiles and the word-check query).
|
||||
func bytesToInts(bs []byte) []int {
|
||||
out := make([]int, len(bs))
|
||||
for i, b := range bs {
|
||||
@@ -429,7 +429,7 @@ func nudgeHandler(backend *backendclient.Client) Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// getDraftHandler returns the player's saved composition (Stage 17). It reuses
|
||||
// getDraftHandler returns the player's saved composition. It reuses
|
||||
// GameActionRequest for the game id and wraps the backend's raw JSON in a DraftView.
|
||||
func getDraftHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
@@ -442,7 +442,7 @@ func getDraftHandler(backend *backendclient.Client) Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// saveDraftHandler upserts the player's composition (Stage 17), forwarding the opaque JSON
|
||||
// saveDraftHandler upserts the player's composition, forwarding the opaque JSON
|
||||
// string verbatim. It echoes an empty DraftView as a well-formed acknowledgement.
|
||||
func saveDraftHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
@@ -454,7 +454,7 @@ func saveDraftHandler(backend *backendclient.Client) Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// hideGameHandler hides a finished game from the caller's own list (Stage 17). It reuses
|
||||
// hideGameHandler hides a finished game from the caller's own list. It reuses
|
||||
// GameActionRequest for the game id and echoes an Ack.
|
||||
func hideGameHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
// TestGameStateIncludesAlphabet checks the include_alphabet flag reaches the backend and
|
||||
// the returned alphabet table plus the index rack (a blank is 255) are encoded into the
|
||||
// StateView (Stage 13).
|
||||
// StateView.
|
||||
func TestGameStateIncludesAlphabet(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("include_alphabet"); got != "true" {
|
||||
@@ -85,7 +85,7 @@ func TestGameStateOmitsAlphabetByDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSubmitPlayForwardsIndexTiles checks PlayTile indices reach the backend as integer
|
||||
// letter fields in the JSON body, blank flag preserved (Stage 13).
|
||||
// letter fields in the JSON body, blank flag preserved.
|
||||
func TestSubmitPlayForwardsIndexTiles(t *testing.T) {
|
||||
var body struct {
|
||||
Dir string `json:"dir"`
|
||||
@@ -135,7 +135,7 @@ func TestSubmitPlayForwardsIndexTiles(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestCheckWordForwardsIndices checks the word-check query rides as repeated ?idx= params
|
||||
// and the decoded concrete word echoes back (Stage 13).
|
||||
// and the decoded concrete word echoes back.
|
||||
func TestCheckWordForwardsIndices(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query()["idx"]; len(got) != 3 || got[0] != "2" || got[2] != "19" {
|
||||
@@ -167,7 +167,7 @@ func TestCheckWordForwardsIndices(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestExchangeForwardsIndices checks rack-exchange indices (blank 255) reach the backend
|
||||
// body (Stage 13).
|
||||
// body.
|
||||
func TestExchangeForwardsIndices(t *testing.T) {
|
||||
var body struct {
|
||||
Tiles []int `json:"tiles"`
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// TestDraftSaveForwardsRawJSON checks the save handler forwards the client's composition JSON
|
||||
// to the backend verbatim (the "no double-encode" contract, Stage 17) with the user header.
|
||||
// to the backend verbatim (the "no double-encode" contract) with the user header.
|
||||
func TestDraftSaveForwardsRawJSON(t *testing.T) {
|
||||
const body = `{"rack_order":"1,0","board_tiles":[{"row":7,"col":7,"letter":"Q","blank":false}]}`
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// Stage 11 account linking & merge message types. The email ops carry the costly-
|
||||
// Account linking & merge message types. The email ops carry the costly-
|
||||
// email rate flag; the telegram ops validate Login Widget data through the
|
||||
// connector (registered only when the connector is configured). All are
|
||||
// authenticated. The merge ops are the explicit irreversible step, gated in the UI
|
||||
@@ -20,11 +20,11 @@ const (
|
||||
MsgLinkTelegramMerge = "link.telegram.merge"
|
||||
)
|
||||
|
||||
// registerStage11 adds the linking & merge operations. The telegram ops need the
|
||||
// registerLinkOps adds the linking & merge operations. The telegram ops need the
|
||||
// connector's Login Widget validator, so they are registered only when tg is set.
|
||||
// supportedLangs is the variant gating set for a switched link session (the link
|
||||
// flows run on the web, so the gateway default set).
|
||||
func registerStage11(r *Registry, backend *backendclient.Client, tg TelegramValidator, supportedLangs []string) {
|
||||
func registerLinkOps(r *Registry, backend *backendclient.Client, tg TelegramValidator, supportedLangs []string) {
|
||||
r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true}
|
||||
r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend, supportedLangs), Auth: true, Email: true}
|
||||
r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend, supportedLangs), Auth: true, Email: true}
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// Stage 8 message types: friends (incl. the one-time code path), per-user blocks,
|
||||
// Message types: friends (incl. the one-time code path), per-user blocks,
|
||||
// friend-game invitations, profile editing + email binding, statistics and GCG
|
||||
// export. All are authenticated. Registered by registerStage8 from NewRegistry.
|
||||
// export. All are authenticated. Registered by registerSocialOps from NewRegistry.
|
||||
const (
|
||||
MsgFriendsList = "friends.list"
|
||||
MsgFriendsIncoming = "friends.incoming"
|
||||
@@ -33,9 +33,9 @@ const (
|
||||
MsgGameGCG = "game.gcg"
|
||||
)
|
||||
|
||||
// registerStage8 adds the Stage 8 social, account and history operations to the
|
||||
// registerSocialOps adds the social, account and history operations to the
|
||||
// registry (all authenticated; the email-bind ops carry the costly-email flag).
|
||||
func registerStage8(r *Registry, backend *backendclient.Client) {
|
||||
func registerSocialOps(r *Registry, backend *backendclient.Client) {
|
||||
r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true}
|
||||
r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true}
|
||||
r.ops[MsgFriendsOutgoing] = Op{Handler: friendsOutgoingHandler(backend), Auth: true}
|
||||
|
||||
@@ -151,7 +151,7 @@ func gameActionPayload(gameID string) []byte {
|
||||
}
|
||||
|
||||
// TestHideGameForwardsToBackend checks game.hide reuses GameActionRequest, POSTs to the
|
||||
// game's /hide endpoint with the caller's id, and echoes an Ack (Stage 17).
|
||||
// game's /hide endpoint with the caller's id, and echoes an Ack.
|
||||
func TestHideGameForwardsToBackend(t *testing.T) {
|
||||
var hit bool
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
// The committed dist/ holds only a placeholder index.html so the gateway module
|
||||
// compiles with a plain `go build` (and in CI) without a UI build. The production
|
||||
// gateway image replaces dist/ with the real Vite build — minus landing.html, which
|
||||
// ships in the separate landing container since R3 — before compiling (see
|
||||
// ships in the separate landing container — before compiling (see
|
||||
// gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built
|
||||
// with a relative asset base, one build serves under any path: the game SPA is
|
||||
// mounted at /app/ (web) and /telegram/ (the Telegram Mini App) — the single-origin
|
||||
// model in docs/ARCHITECTURE.md §13.
|
||||
//
|
||||
// Caching (Stage 17): Vite emits hash-named files under assets/, so those are immutable and
|
||||
// Caching: Vite emits hash-named files under assets/, so those are immutable and
|
||||
// cached hard (a reload/relaunch is a cache hit, not a re-download); the HTML shells carry
|
||||
// no-cache so a new deploy is picked up immediately.
|
||||
package webui
|
||||
|
||||
Reference in New Issue
Block a user