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