Stage 6: gateway edge (Connect/FlatBuffers over h2c, platform/email/guest auth, sessions, rate-limit, admin passthrough, live push bridge)
Tests · Go / test (push) Successful in 8s
Tests · Integration / integration (push) Successful in 11s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 10s

New public ingress and the first network edge. Framework + a vertical slice of
operations end-to-end; remaining ops reuse the same transcode pattern in Stage 7.

Contracts (new module scrabble/pkg):
- push.proto (backend->gateway gRPC server-stream) + scrabble.fbs (FlatBuffers
  edge payloads), committed generated Go; buf/flatc Makefiles (dev-time codegen).

Backend:
- REST handlers on the /api/v1 groups: internal session endpoints
  (telegram/guest/email login -> mint, resolve, revoke) and the user slice
  (profile, submit_play, state, lobby enqueue/poll, chat).
- internal/notify in-process Publisher hub + internal/pushgrpc gRPC server
  (BACKEND_GRPC_ADDR) streaming your_turn/opponent_moved/chat/nudge/match_found;
  emission in game.commit, social, matchmaker.
- migration 00005 accounts.is_guest; guests are durable rows excluded from stats;
  ProvisionGuest; email-as-login (RequestLoginCode/LoginWithCode).

Gateway (new module scrabble/gateway):
- Connect Gateway service over h2c (Execute + Subscribe), FlatBuffers<->JSON
  transcode registry, Telegram initData HMAC validator (seam), session cache,
  token-bucket rate limiter (3 classes), push fan-out hub, backend REST + push
  gRPC client, admin Basic-Auth reverse proxy.

go.work: use ./pkg, ./gateway + replace scrabble/pkg. CI: gateway/**, pkg/**
path filters; unit build/vet/test span all three modules. Docs (PLAN,
ARCHITECTURE, FUNCTIONAL+ru, TESTING, READMEs) updated; gateway/pkg unit tests +
guest/email-login integration tests.
This commit is contained in:
Ilia Denisov
2026-06-02 22:38:24 +02:00
parent 104eb2a978
commit 408da3f201
98 changed files with 8134 additions and 57 deletions
+24
View File
@@ -0,0 +1,24 @@
# Code generation for the gateway's Connect edge contract. The generated Go is
# COMMITTED; CI only builds it (the same dev-time model as backend/cmd/jetgen and
# pkg/Makefile). The FlatBuffers payloads live in pkg (generate them with
# `make -C ../pkg fbs`).
#
# Prerequisites:
# make tools # go install the local protoc-gen-* plugins
# Then:
# make gen # buf generate (protobuf-go + connect-go)
.PHONY: gen tools
GOBIN := $(shell go env GOBIN)
ifeq ($(GOBIN),)
GOBIN := $(shell go env GOPATH)/bin
endif
# tools installs the local buf plugins, pinned to the connect-go and protobuf
# runtime versions in go.mod.
tools:
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11
go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.19.2
gen:
PATH="$(GOBIN):$$PATH" buf generate
+95
View File
@@ -0,0 +1,95 @@
# gateway
The Scrabble platform's only public ingress (module `scrabble/gateway`). It
terminates the client's **Connect-RPC + FlatBuffers** traffic over HTTP/2
cleartext (`h2c`), authenticates the originating credential, mints/resolves a
thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
backend over REST/JSON, and bridges the backend's gRPC push stream to each
client's in-app live channel. It also fronts the backend admin API behind HTTP
Basic-Auth. See [`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10,
§12.
## Package layout
```
cmd/gateway/ # main: config -> backend client -> session cache ->
# push hub -> Connect h2c server (+ admin) -> serve
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)
internal/auth/ # Telegram initData HMAC validator (seam + fixtures)
internal/push/ # live-event fan-out hub (per-user client streams)
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
internal/connectsrv/ # the Connect Gateway service over h2c
internal/admin/ # Basic-Auth reverse proxy to the backend admin API
```
The FlatBuffers payloads and the backend push proto are the shared wire
contracts in [`../pkg`](../pkg).
## Transport contract
A single `Gateway` Connect service: `Execute(message_type, payload, request_id)`
for unary operations and `Subscribe` for the live stream. The `payload` bytes are
FlatBuffers tables (`scrabble/pkg/fbs`); the gateway transcodes them to and from
the backend's JSON. The session token rides in `Authorization: Bearer`; `auth.*`
operations are unauthenticated and return the minted token. A unary domain
outcome rides back in `ExecuteResponse.result_code` (HTTP 200); only edge
failures become Connect error codes.
The Stage 6 message-type slice: `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`. Further
operations follow the same transcode pattern (added in Stage 7).
## Configuration
| Variable | Default | Notes |
| --- | --- | --- |
| `GATEWAY_HTTP_ADDR` | `:8081` | public Connect/h2c listener |
| `GATEWAY_ADMIN_ADDR` | `:8082` | admin proxy listener (enabled only with creds) |
| `GATEWAY_LOG_LEVEL` | `info` | zap level |
| `GATEWAY_BACKEND_HTTP_URL` | `http://localhost:8080` | backend REST base URL |
| `GATEWAY_BACKEND_GRPC_ADDR` | `localhost:9090` | backend push gRPC address |
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin proxy |
| `GATEWAY_TELEGRAM_BOT_TOKEN` | unset | enable the Telegram auth path |
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `15s` | live-stream keep-alive |
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated
120/min·user (burst 40), admin 60/min·IP (burst 20), email-code 5/10 min·IP.
## Run
```sh
GATEWAY_BACKEND_HTTP_URL=http://localhost:8080 \
GATEWAY_BACKEND_GRPC_ADDR=localhost:9090 \
go run ./gateway/cmd/gateway # Connect edge on :8081
```
## Generated code
The Connect envelope Go is committed under `proto/edge/v1`. Regenerate after
editing the `.proto` (dev-time, like `backend/cmd/jetgen`):
```sh
make -C gateway tools # go install protoc-gen-go + protoc-gen-connect-go
make -C gateway gen # buf generate (local plugins)
```
The FlatBuffers payloads are generated in [`../pkg`](../pkg) (`make -C pkg fbs`).
## Tests
```sh
go test -count=1 ./gateway/...
```
All gateway tests are hermetic: no real network, a fake backend (`httptest`) and
credential fixtures. There is no integration (Docker) suite — the gateway holds
no database.
+13
View File
@@ -0,0 +1,13 @@
version: v2
# Local plugins (go install via `make tools`) so generation needs no buf.build
# remote fetch. Output is committed; CI only builds it.
plugins:
- local: protoc-gen-go
out: proto
opt:
- paths=source_relative
- local: protoc-gen-connect-go
out: proto
opt:
- paths=source_relative
+9
View File
@@ -0,0 +1,9 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
+210
View File
@@ -0,0 +1,210 @@
// Command gateway is the Scrabble platform's only public ingress. It terminates
// the client's Connect-RPC/FlatBuffers traffic over h2c, validates platform /
// email / guest credentials and mints opaque sessions, rate-limits, injects
// X-User-ID when forwarding to the backend over REST, and bridges the backend's
// gRPC push stream to each client's in-app live channel. It also fronts the
// backend admin API behind HTTP Basic-Auth.
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
"go.uber.org/zap"
"scrabble/gateway/internal/admin"
"scrabble/gateway/internal/auth"
"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"
)
const (
// shutdownTimeout bounds the graceful HTTP shutdown.
shutdownTimeout = 10 * time.Second
// pushReconnectDelay is the pause before re-subscribing to the backend push
// stream after it ends.
pushReconnectDelay = 2 * time.Second
// gatewayID identifies this gateway instance to the backend push channel.
gatewayID = "gateway"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("gateway: load config: %v", err)
}
logger, err := newLogger(cfg.LogLevel)
if err != nil {
log.Fatalf("gateway: build logger: %v", err)
}
defer func() { _ = logger.Sync() }()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx, cfg, logger); err != nil {
logger.Fatal("gateway: terminated", zap.Error(err))
}
}
// run wires the gateway dependencies and serves the public (and optional admin)
// listeners until the context is cancelled.
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
backend, err := backendclient.New(cfg.BackendHTTPURL, cfg.BackendGRPCAddr, cfg.BackendTimeout)
if err != nil {
return err
}
defer func() { _ = backend.Close() }()
sessions := session.NewCache(backend, cfg.SessionTTL, cfg.SessionCacheMax)
limiter := ratelimit.New()
hub := push.NewHub(0)
var tg auth.TelegramValidator
if cfg.TelegramBotToken != "" {
tg = auth.NewHMACValidator(cfg.TelegramBotToken)
} else {
logger.Warn("telegram auth disabled (GATEWAY_TELEGRAM_BOT_TOKEN unset)")
}
registry := transcode.NewRegistry(backend, tg)
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry,
Sessions: sessions,
Limiter: limiter,
Hub: hub,
RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval,
Logger: logger,
})
// Bridge the backend push stream into the fan-out hub.
go runPushPump(ctx, backend, hub, logger)
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler()}
servers := []*namedServer{{name: "public", srv: public}}
if cfg.AdminEnabled() {
proxy, err := admin.NewProxy(cfg.BackendHTTPURL, cfg.AdminUser, cfg.AdminPassword, logger)
if err != nil {
return err
}
servers = append(servers, &namedServer{name: "admin", srv: &http.Server{Addr: cfg.AdminAddr, Handler: proxy}})
} else {
logger.Info("admin proxy disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
}
logger.Info("gateway starting",
zap.String("http_addr", cfg.HTTPAddr),
zap.String("backend_http", cfg.BackendHTTPURL),
zap.String("backend_grpc", cfg.BackendGRPCAddr))
return runServers(ctx, cancel, servers, logger)
}
// namedServer pairs an HTTP server with a label for diagnostics.
type namedServer struct {
name string
srv *http.Server
}
// runServers serves every listener and shuts them all down when the first one
// stops or the context is cancelled.
func runServers(ctx context.Context, cancel context.CancelFunc, servers []*namedServer, logger *zap.Logger) error {
errc := make(chan error, len(servers))
for _, s := range servers {
go func(s *namedServer) {
logger.Info("listener starting", zap.String("server", s.name), zap.String("addr", s.srv.Addr))
err := s.srv.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
errc <- err
}(s)
}
var first error
select {
case <-ctx.Done():
case first = <-errc:
}
cancel()
shutdownCtx, sc := context.WithTimeout(context.Background(), shutdownTimeout)
defer sc()
for _, s := range servers {
if err := s.srv.Shutdown(shutdownCtx); err != nil {
logger.Warn("listener shutdown", zap.String("server", s.name), zap.Error(err))
}
}
return first
}
// runPushPump keeps a backend push subscription open, forwarding every event to
// the hub and re-subscribing after the stream ends, until the context is done.
func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.Hub, logger *zap.Logger) {
for ctx.Err() == nil {
stream, err := backend.SubscribePush(ctx, gatewayID)
if err != nil {
logger.Warn("push subscribe failed", zap.Error(err))
if !sleep(ctx, pushReconnectDelay) {
return
}
continue
}
for {
ev, err := stream.Recv()
if err != nil {
if ctx.Err() == nil {
logger.Warn("push stream ended", zap.Error(err))
}
break
}
hub.Publish(push.Event{
UserID: ev.GetUserId(),
Kind: ev.GetKind(),
Payload: ev.GetPayload(),
EventID: ev.GetEventId(),
})
}
if !sleep(ctx, pushReconnectDelay) {
return
}
}
}
// sleep waits for d or until ctx is cancelled, reporting whether it waited the
// full duration.
func sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// newLogger builds a production JSON logger at the given level.
func newLogger(level string) (*zap.Logger, error) {
var lvl zap.AtomicLevel
if err := lvl.UnmarshalText([]byte(level)); err != nil {
return nil, err
}
cfg := zap.NewProductionConfig()
cfg.Level = lvl
return cfg.Build()
}
+21
View File
@@ -0,0 +1,21 @@
module scrabble/gateway
go 1.26.3
require (
connectrpc.com/connect v1.19.2
github.com/google/flatbuffers v23.5.26+incompatible
go.uber.org/zap v1.27.1
golang.org/x/net v0.53.0
golang.org/x/time v0.15.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
scrabble/pkg v0.0.0
)
require (
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
)
+58
View File
@@ -0,0 +1,58 @@
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg=
github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+64
View File
@@ -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
}
+73
View File
@@ -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)
}
}
+139
View File
@@ -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)
}
+92
View File
@@ -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")
}
}
+191
View File
@@ -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
}
+122
View File
@@ -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})
}
+175
View File
@@ -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
}
+20
View File
@@ -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)
}
+209
View File
@@ -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))
}
}
+88
View File
@@ -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)
}
+56
View File
@@ -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
}
+87
View File
@@ -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)
}
}
+108
View File
@@ -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)
}
}
+74
View File
@@ -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)
}
}
+209
View File
@@ -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))
}
+221
View File
@@ -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)
}
}
+334
View File
@@ -0,0 +1,334 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: edge/v1/edge.proto
// Package scrabble.edge.v1 is the client <-> gateway Connect-RPC contract. It is
// deliberately minimal (ARCHITECTURE.md §2): a single unary Execute that routes
// by message_type, and a server-streaming Subscribe for the in-app live channel.
// The actual request/response and event bodies travel as FlatBuffers bytes in the
// payload fields (pkg/fbs). The session token rides in the Authorization header,
// not the envelope (no per-request signing — ARCHITECTURE.md §3).
package edgev1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// ExecuteRequest is the unary envelope. message_type selects the operation;
// payload is its FlatBuffers-encoded request body; request_id is an optional
// client correlation id echoed back.
type ExecuteRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
MessageType string `protobuf:"bytes,1,opt,name=message_type,json=messageType,proto3" json:"message_type,omitempty"`
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"`
RequestId string `protobuf:"bytes,3,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecuteRequest) Reset() {
*x = ExecuteRequest{}
mi := &file_edge_v1_edge_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecuteRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecuteRequest) ProtoMessage() {}
func (x *ExecuteRequest) ProtoReflect() protoreflect.Message {
mi := &file_edge_v1_edge_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecuteRequest.ProtoReflect.Descriptor instead.
func (*ExecuteRequest) Descriptor() ([]byte, []int) {
return file_edge_v1_edge_proto_rawDescGZIP(), []int{0}
}
func (x *ExecuteRequest) GetMessageType() string {
if x != nil {
return x.MessageType
}
return ""
}
func (x *ExecuteRequest) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
func (x *ExecuteRequest) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
// ExecuteResponse is the unary reply. result_code is "ok" on success or a stable
// error code; payload is the FlatBuffers-encoded response body (empty on error).
type ExecuteResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
ResultCode string `protobuf:"bytes,2,opt,name=result_code,json=resultCode,proto3" json:"result_code,omitempty"`
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecuteResponse) Reset() {
*x = ExecuteResponse{}
mi := &file_edge_v1_edge_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecuteResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecuteResponse) ProtoMessage() {}
func (x *ExecuteResponse) ProtoReflect() protoreflect.Message {
mi := &file_edge_v1_edge_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecuteResponse.ProtoReflect.Descriptor instead.
func (*ExecuteResponse) Descriptor() ([]byte, []int) {
return file_edge_v1_edge_proto_rawDescGZIP(), []int{1}
}
func (x *ExecuteResponse) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *ExecuteResponse) GetResultCode() string {
if x != nil {
return x.ResultCode
}
return ""
}
func (x *ExecuteResponse) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
// SubscribeRequest opens the live stream. It is empty: the session is taken from
// the Authorization header.
type SubscribeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SubscribeRequest) Reset() {
*x = SubscribeRequest{}
mi := &file_edge_v1_edge_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SubscribeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SubscribeRequest) ProtoMessage() {}
func (x *SubscribeRequest) ProtoReflect() protoreflect.Message {
mi := &file_edge_v1_edge_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead.
func (*SubscribeRequest) Descriptor() ([]byte, []int) {
return file_edge_v1_edge_proto_rawDescGZIP(), []int{2}
}
// Event is one live event. kind is the notification catalog kind; payload is its
// FlatBuffers-encoded body; event_id is a correlation id.
type Event struct {
state protoimpl.MessageState `protogen:"open.v1"`
Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"`
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"`
EventId string `protobuf:"bytes,3,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Event) Reset() {
*x = Event{}
mi := &file_edge_v1_edge_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Event) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Event) ProtoMessage() {}
func (x *Event) ProtoReflect() protoreflect.Message {
mi := &file_edge_v1_edge_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Event.ProtoReflect.Descriptor instead.
func (*Event) Descriptor() ([]byte, []int) {
return file_edge_v1_edge_proto_rawDescGZIP(), []int{3}
}
func (x *Event) GetKind() string {
if x != nil {
return x.Kind
}
return ""
}
func (x *Event) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
func (x *Event) GetEventId() string {
if x != nil {
return x.EventId
}
return ""
}
var File_edge_v1_edge_proto protoreflect.FileDescriptor
const file_edge_v1_edge_proto_rawDesc = "" +
"\n" +
"\x12edge/v1/edge.proto\x12\x10scrabble.edge.v1\"l\n" +
"\x0eExecuteRequest\x12!\n" +
"\fmessage_type\x18\x01 \x01(\tR\vmessageType\x12\x18\n" +
"\apayload\x18\x02 \x01(\fR\apayload\x12\x1d\n" +
"\n" +
"request_id\x18\x03 \x01(\tR\trequestId\"k\n" +
"\x0fExecuteResponse\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n" +
"\vresult_code\x18\x02 \x01(\tR\n" +
"resultCode\x12\x18\n" +
"\apayload\x18\x03 \x01(\fR\apayload\"\x12\n" +
"\x10SubscribeRequest\"P\n" +
"\x05Event\x12\x12\n" +
"\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n" +
"\apayload\x18\x02 \x01(\fR\apayload\x12\x19\n" +
"\bevent_id\x18\x03 \x01(\tR\aeventId2\xa5\x01\n" +
"\aGateway\x12N\n" +
"\aExecute\x12 .scrabble.edge.v1.ExecuteRequest\x1a!.scrabble.edge.v1.ExecuteResponse\x12J\n" +
"\tSubscribe\x12\".scrabble.edge.v1.SubscribeRequest\x1a\x17.scrabble.edge.v1.Event0\x01B'Z%scrabble/gateway/proto/edge/v1;edgev1b\x06proto3"
var (
file_edge_v1_edge_proto_rawDescOnce sync.Once
file_edge_v1_edge_proto_rawDescData []byte
)
func file_edge_v1_edge_proto_rawDescGZIP() []byte {
file_edge_v1_edge_proto_rawDescOnce.Do(func() {
file_edge_v1_edge_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_edge_v1_edge_proto_rawDesc), len(file_edge_v1_edge_proto_rawDesc)))
})
return file_edge_v1_edge_proto_rawDescData
}
var file_edge_v1_edge_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_edge_v1_edge_proto_goTypes = []any{
(*ExecuteRequest)(nil), // 0: scrabble.edge.v1.ExecuteRequest
(*ExecuteResponse)(nil), // 1: scrabble.edge.v1.ExecuteResponse
(*SubscribeRequest)(nil), // 2: scrabble.edge.v1.SubscribeRequest
(*Event)(nil), // 3: scrabble.edge.v1.Event
}
var file_edge_v1_edge_proto_depIdxs = []int32{
0, // 0: scrabble.edge.v1.Gateway.Execute:input_type -> scrabble.edge.v1.ExecuteRequest
2, // 1: scrabble.edge.v1.Gateway.Subscribe:input_type -> scrabble.edge.v1.SubscribeRequest
1, // 2: scrabble.edge.v1.Gateway.Execute:output_type -> scrabble.edge.v1.ExecuteResponse
3, // 3: scrabble.edge.v1.Gateway.Subscribe:output_type -> scrabble.edge.v1.Event
2, // [2:4] is the sub-list for method output_type
0, // [0:2] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_edge_v1_edge_proto_init() }
func file_edge_v1_edge_proto_init() {
if File_edge_v1_edge_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_edge_v1_edge_proto_rawDesc), len(file_edge_v1_edge_proto_rawDesc)),
NumEnums: 0,
NumMessages: 4,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_edge_v1_edge_proto_goTypes,
DependencyIndexes: file_edge_v1_edge_proto_depIdxs,
MessageInfos: file_edge_v1_edge_proto_msgTypes,
}.Build()
File_edge_v1_edge_proto = out.File
file_edge_v1_edge_proto_goTypes = nil
file_edge_v1_edge_proto_depIdxs = nil
}
+50
View File
@@ -0,0 +1,50 @@
syntax = "proto3";
// Package scrabble.edge.v1 is the client <-> gateway Connect-RPC contract. It is
// deliberately minimal (ARCHITECTURE.md §2): a single unary Execute that routes
// by message_type, and a server-streaming Subscribe for the in-app live channel.
// The actual request/response and event bodies travel as FlatBuffers bytes in the
// payload fields (pkg/fbs). The session token rides in the Authorization header,
// not the envelope (no per-request signing — ARCHITECTURE.md §3).
package scrabble.edge.v1;
option go_package = "scrabble/gateway/proto/edge/v1;edgev1";
// Gateway is the public edge service.
service Gateway {
// Execute runs one unary operation identified by message_type. Auth operations
// (auth.*) are unauthenticated and return a minted session; all others require
// a valid session token in the Authorization header.
rpc Execute(ExecuteRequest) returns (ExecuteResponse);
// Subscribe opens the in-app live-event stream for the authenticated session.
rpc Subscribe(SubscribeRequest) returns (stream Event);
}
// ExecuteRequest is the unary envelope. message_type selects the operation;
// payload is its FlatBuffers-encoded request body; request_id is an optional
// client correlation id echoed back.
message ExecuteRequest {
string message_type = 1;
bytes payload = 2;
string request_id = 3;
}
// ExecuteResponse is the unary reply. result_code is "ok" on success or a stable
// error code; payload is the FlatBuffers-encoded response body (empty on error).
message ExecuteResponse {
string request_id = 1;
string result_code = 2;
bytes payload = 3;
}
// SubscribeRequest opens the live stream. It is empty: the session is taken from
// the Authorization header.
message SubscribeRequest {}
// Event is one live event. kind is the notification catalog kind; payload is its
// FlatBuffers-encoded body; event_id is a correlation id.
message Event {
string kind = 1;
bytes payload = 2;
string event_id = 3;
}
@@ -0,0 +1,150 @@
// Code generated by protoc-gen-connect-go. DO NOT EDIT.
//
// Source: edge/v1/edge.proto
// Package scrabble.edge.v1 is the client <-> gateway Connect-RPC contract. It is
// deliberately minimal (ARCHITECTURE.md §2): a single unary Execute that routes
// by message_type, and a server-streaming Subscribe for the in-app live channel.
// The actual request/response and event bodies travel as FlatBuffers bytes in the
// payload fields (pkg/fbs). The session token rides in the Authorization header,
// not the envelope (no per-request signing — ARCHITECTURE.md §3).
package edgev1connect
import (
connect "connectrpc.com/connect"
context "context"
errors "errors"
http "net/http"
v1 "scrabble/gateway/proto/edge/v1"
strings "strings"
)
// This is a compile-time assertion to ensure that this generated file and the connect package are
// compatible. If you get a compiler error that this constant is not defined, this code was
// generated with a version of connect newer than the one compiled into your binary. You can fix the
// problem by either regenerating this code with an older version of connect or updating the connect
// version compiled into your binary.
const _ = connect.IsAtLeastVersion1_13_0
const (
// GatewayName is the fully-qualified name of the Gateway service.
GatewayName = "scrabble.edge.v1.Gateway"
)
// These constants are the fully-qualified names of the RPCs defined in this package. They're
// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
//
// Note that these are different from the fully-qualified method names used by
// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
// period.
const (
// GatewayExecuteProcedure is the fully-qualified name of the Gateway's Execute RPC.
GatewayExecuteProcedure = "/scrabble.edge.v1.Gateway/Execute"
// GatewaySubscribeProcedure is the fully-qualified name of the Gateway's Subscribe RPC.
GatewaySubscribeProcedure = "/scrabble.edge.v1.Gateway/Subscribe"
)
// GatewayClient is a client for the scrabble.edge.v1.Gateway service.
type GatewayClient interface {
// Execute runs one unary operation identified by message_type. Auth operations
// (auth.*) are unauthenticated and return a minted session; all others require
// a valid session token in the Authorization header.
Execute(context.Context, *connect.Request[v1.ExecuteRequest]) (*connect.Response[v1.ExecuteResponse], error)
// Subscribe opens the in-app live-event stream for the authenticated session.
Subscribe(context.Context, *connect.Request[v1.SubscribeRequest]) (*connect.ServerStreamForClient[v1.Event], error)
}
// NewGatewayClient constructs a client for the scrabble.edge.v1.Gateway service. By default, it
// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends
// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or
// connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewGatewayClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) GatewayClient {
baseURL = strings.TrimRight(baseURL, "/")
gatewayMethods := v1.File_edge_v1_edge_proto.Services().ByName("Gateway").Methods()
return &gatewayClient{
execute: connect.NewClient[v1.ExecuteRequest, v1.ExecuteResponse](
httpClient,
baseURL+GatewayExecuteProcedure,
connect.WithSchema(gatewayMethods.ByName("Execute")),
connect.WithClientOptions(opts...),
),
subscribe: connect.NewClient[v1.SubscribeRequest, v1.Event](
httpClient,
baseURL+GatewaySubscribeProcedure,
connect.WithSchema(gatewayMethods.ByName("Subscribe")),
connect.WithClientOptions(opts...),
),
}
}
// gatewayClient implements GatewayClient.
type gatewayClient struct {
execute *connect.Client[v1.ExecuteRequest, v1.ExecuteResponse]
subscribe *connect.Client[v1.SubscribeRequest, v1.Event]
}
// Execute calls scrabble.edge.v1.Gateway.Execute.
func (c *gatewayClient) Execute(ctx context.Context, req *connect.Request[v1.ExecuteRequest]) (*connect.Response[v1.ExecuteResponse], error) {
return c.execute.CallUnary(ctx, req)
}
// Subscribe calls scrabble.edge.v1.Gateway.Subscribe.
func (c *gatewayClient) Subscribe(ctx context.Context, req *connect.Request[v1.SubscribeRequest]) (*connect.ServerStreamForClient[v1.Event], error) {
return c.subscribe.CallServerStream(ctx, req)
}
// GatewayHandler is an implementation of the scrabble.edge.v1.Gateway service.
type GatewayHandler interface {
// Execute runs one unary operation identified by message_type. Auth operations
// (auth.*) are unauthenticated and return a minted session; all others require
// a valid session token in the Authorization header.
Execute(context.Context, *connect.Request[v1.ExecuteRequest]) (*connect.Response[v1.ExecuteResponse], error)
// Subscribe opens the in-app live-event stream for the authenticated session.
Subscribe(context.Context, *connect.Request[v1.SubscribeRequest], *connect.ServerStream[v1.Event]) error
}
// NewGatewayHandler builds an HTTP handler from the service implementation. It returns the path on
// which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewGatewayHandler(svc GatewayHandler, opts ...connect.HandlerOption) (string, http.Handler) {
gatewayMethods := v1.File_edge_v1_edge_proto.Services().ByName("Gateway").Methods()
gatewayExecuteHandler := connect.NewUnaryHandler(
GatewayExecuteProcedure,
svc.Execute,
connect.WithSchema(gatewayMethods.ByName("Execute")),
connect.WithHandlerOptions(opts...),
)
gatewaySubscribeHandler := connect.NewServerStreamHandler(
GatewaySubscribeProcedure,
svc.Subscribe,
connect.WithSchema(gatewayMethods.ByName("Subscribe")),
connect.WithHandlerOptions(opts...),
)
return "/scrabble.edge.v1.Gateway/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case GatewayExecuteProcedure:
gatewayExecuteHandler.ServeHTTP(w, r)
case GatewaySubscribeProcedure:
gatewaySubscribeHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedGatewayHandler returns CodeUnimplemented from all methods.
type UnimplementedGatewayHandler struct{}
func (UnimplementedGatewayHandler) Execute(context.Context, *connect.Request[v1.ExecuteRequest]) (*connect.Response[v1.ExecuteResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("scrabble.edge.v1.Gateway.Execute is not implemented"))
}
func (UnimplementedGatewayHandler) Subscribe(context.Context, *connect.Request[v1.SubscribeRequest], *connect.ServerStream[v1.Event]) error {
return connect.NewError(connect.CodeUnimplemented, errors.New("scrabble.edge.v1.Gateway.Subscribe is not implemented"))
}