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:
Ilia Denisov
2026-06-10 16:56:03 +02:00
parent a372343797
commit 8881214213
156 changed files with 749 additions and 778 deletions
+2 -2
View File
@@ -42,7 +42,7 @@ COPY ui ./
RUN pnpm build
# --- landing -------------------------------------------------------------------
# The public landing page as its own static container (R3): the same Vite build
# The public landing page as its own static container: the same Vite build
# served by caddy at /, so stray public traffic is absorbed by static file
# serving and never reaches the Go edge.
FROM caddy:2-alpine AS landing
@@ -58,7 +58,7 @@ COPY gateway ./gateway
# Replace the committed placeholder with the freshly built UI before compiling, so
# go:embed bakes the real bundle into the binary. The landing shell ships in the
# landing image, not in the gateway (R3).
# landing image, not in the gateway.
RUN rm -rf gateway/internal/webui/dist
COPY --from=ui /ui/dist gateway/internal/webui/dist
RUN rm gateway/internal/webui/dist/landing.html
+13 -13
View File
@@ -23,7 +23,7 @@ proto/edge/v1/ # Connect envelope contract (committed generated Go)
internal/config/ # GATEWAY_* env config
internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client
internal/session/ # in-memory session cache (LRU/TTL, backend fallback)
internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate) + the rejection tracker (R3)
internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate) + the rejection tracker
internal/connector/ # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing
internal/push/ # live-event fan-out hub (per-user client streams)
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
@@ -50,21 +50,21 @@ failures become Connect error codes.
out-of-app push to that connector for recipients with no live in-app stream
(ARCHITECTURE.md §10). When `GATEWAY_CONNECTOR_ADDR` is unset, both are disabled.
The Stage 6 message-type slice: `auth.telegram`, `auth.guest`,
The message-type catalog: `auth.telegram`, `auth.guest`,
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events
`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (R4 enriched the game events
and `game_over`/`notify` — to carry the state delta the client applies without a `game.state`
refetch). Stage 7
added the play-loop ops; **Stage 8** added the social/account/history ops —
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post` and the play-loop ops;
live events
`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (the game events —
and `game_over`/`notify` — carry the state delta the client applies without a `game.state`
refetch). The social/account/history ops —
`friends.*` (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem),
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
`stats.get`, `game.gcg`, and the `notify` live event — all via the identical
transcode pattern (`transcode_social.go`). **Stage 11** added account linking & merge
`stats.get`, `game.gcg`, and the `notify` live event — go through the identical
transcode pattern (`transcode_social.go`). Account linking & merge
`link.email.request/confirm/merge` and `link.telegram.confirm/merge`
(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the
connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These
**superseded** the Stage 8 `email.bind.*` ops, which were removed.
**superseded** the former `email.bind.*` ops, which were removed.
## Configuration
@@ -81,13 +81,13 @@ connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `10s` | live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) |
| `GATEWAY_MAX_BODY_BYTES` | `1048576` | caps one request body and one Connect message read; an oversized Execute is refused with `resource_exhausted` (R3) |
| `GATEWAY_MAX_BODY_BYTES` | `1048576` | caps one request body and one Connect message read; an oversized Execute is refused with `resource_exhausted` |
| `GATEWAY_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` |
| `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
| `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated
300/min·user (burst 80, raised in Stage 17 for multi-device play), admin
300/min·user (burst 80, sized for multi-device play), admin
60/min·IP (burst 20, guarding the `/_gm` mount ahead of its Basic-Auth),
email-code 5/10 min·IP (burst 2).
@@ -95,7 +95,7 @@ Every rejection increments `gateway_rate_limited_total{class}`
(`user`/`public`/`email`/`admin`) and logs one Debug line; a reporter drains the
per-key rejection tracker every 30 s, emits a Warn summary per throttled key and
posts the report to the backend (`/api/v1/internal/ratelimit/report`), feeding
the admin console's throttled view and the high-rate auto-flag (R3).
the admin console's throttled view and the high-rate auto-flag.
## Run
+3 -3
View File
@@ -42,10 +42,10 @@ const (
// readHeaderTimeout bounds reading one request's headers on the public
// listener (a slowloris guard). Bodies and long-lived streams are governed by
// the h2c settings in connectsrv — Read/WriteTimeout stay unset on purpose,
// they would kill the Subscribe stream (R3).
// they would kill the Subscribe stream.
readHeaderTimeout = 10 * time.Second
// throttleReportInterval is the cadence of the rate-limiter rejection
// summary: the Warn log per throttled key and the report to the backend (R3).
// summary: the Warn log per throttled key and the report to the backend.
throttleReportInterval = 30 * time.Second
)
@@ -281,7 +281,7 @@ func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *c
return
}
// A game event carries its own language, so the push comes from the game's bot rather than
// the recipient's last-login bot (Stage 17); other events fall back to the service language.
// the recipient's last-login bot; other events fall back to the service language.
lang := target.Language
if gameLang != "" {
lang = gameLang
+14 -14
View File
@@ -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{}
+5 -5
View File
@@ -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, "",
+1 -1
View File
@@ -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"`
+4 -4
View File
@@ -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,
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
+13 -13
View File
@@ -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))
+4 -4
View File
@@ -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())
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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.
+10 -10
View File
@@ -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) {
+3 -3
View File
@@ -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}
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -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