diff --git a/.gitea/workflows/go-unit.yaml b/.gitea/workflows/go-unit.yaml index 6d8833d..051a23b 100644 --- a/.gitea/workflows/go-unit.yaml +++ b/.gitea/workflows/go-unit.yaml @@ -9,6 +9,8 @@ on: push: paths: - 'backend/**' + - 'gateway/**' + - 'pkg/**' - 'go.work' - 'go.work.sum' - '.gitea/workflows/go-unit.yaml' @@ -16,6 +18,8 @@ on: pull_request: paths: - 'backend/**' + - 'gateway/**' + - 'pkg/**' - 'go.work' - 'go.work.sum' - '.gitea/workflows/go-unit.yaml' @@ -52,10 +56,10 @@ jobs: fi - name: vet - run: go vet ./backend/... + run: go vet ./backend/... ./pkg/... ./gateway/... - name: build - run: go build ./backend/... + run: go build ./backend/... ./pkg/... ./gateway/... - name: test # -count=1 disables the test cache so a green run never depends on a @@ -63,4 +67,4 @@ jobs: # tests at the committed DAWGs in the sibling checkout. env: BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg - run: go test -count=1 ./backend/... + run: go test -count=1 ./backend/... ./pkg/... ./gateway/... diff --git a/.gitea/workflows/integration.yaml b/.gitea/workflows/integration.yaml index 9b9274f..f571def 100644 --- a/.gitea/workflows/integration.yaml +++ b/.gitea/workflows/integration.yaml @@ -11,6 +11,7 @@ on: push: paths: - 'backend/**' + - 'pkg/**' - 'go.work' - 'go.work.sum' - '.gitea/workflows/integration.yaml' @@ -18,6 +19,7 @@ on: pull_request: paths: - 'backend/**' + - 'pkg/**' - 'go.work' - 'go.work.sum' - '.gitea/workflows/integration.yaml' diff --git a/PLAN.md b/PLAN.md index e1f8fbc..ec1d610 100644 --- a/PLAN.md +++ b/PLAN.md @@ -39,7 +39,7 @@ independent (see ARCHITECTURE §9.1). | 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | **done** | | 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** | | 5 | Robot opponent | **done** | -| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | todo | +| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** | | 7 | UI (plain Svelte + Vite, board, lobby, chat, i18n) | todo | | 8 | Telegram integration (bot side-service, deep-link, push) | todo | | 9 | Admin & dictionary ops (complaint review, version reload) | todo | @@ -355,6 +355,67 @@ Open details: deployment target/host; dashboards; load expectations. (10 s), `BACKEND_LOBBY_REAPER_INTERVAL` (1 s). No CI change (both Go workflows already clone the solver sibling and export `BACKEND_DICT_DIR`). +- **Stage 6** (interview + implementation): + - **Scope = framework + vertical slice** (interview): the *whole* edge mechanism + is built and the backend's REST surface + the live-event seam are opened for + the first time, but only a representative slice of operations is wired + end-to-end — auth (`auth.telegram`/`auth.guest`/`auth.email.request`/ + `auth.email.login`), `profile.get`, `game.submit_play`/`game.state`, + `lobby.enqueue`/`lobby.poll`, `chat.post`, all five push events, and the admin + passthrough. The remaining domain operations (friends, blocks, invitations, + hint, word-check, pass/exchange/resign, history/GCG, profile editing) reuse the + identical transcode pattern and are wired in **Stage 7** as the UI needs them. + - **Wire contracts in a new shared `scrabble/pkg` module** (interview): the + backend push proto (`pkg/proto/push/v1`) and the FlatBuffers edge payloads + (`pkg/fbs`, one `scrabblefb` namespace) live here with **committed** generated + Go, imported by both backend and gateway. The Connect envelope proto lives in + `gateway/proto/edge/v1`. Codegen is dev-time (`buf generate` with **local** + plugins + `flatc`, driven by per-module `Makefile`s, mirroring `cmd/jetgen`); + CI only builds the committed output. `pkg` and `gateway` are bare-path modules + like `scrabble-solver`, so `go.work` carries `use ./pkg`, `use ./gateway` and a + `replace scrabble/pkg v0.0.0 => ./pkg` (the no-dot path is not VCS-fetchable); + deps were added with `go mod edit` + `go work sync` (the established no-tidy + pattern). `flatc` is pinned to **23.5.26** to match the `flatbuffers` Go runtime. + - **Guest = durable account + `is_guest`** (interview): migration `00005` adds + `accounts.is_guest`; a guest is a durable row with **no identity** (so the + `sessions`/`game_players` foreign keys hold) that is **excluded from statistics** + (the finish-time recompute skips guest seats) and from friends/history. The + earlier "guests never reach this table" comments and §3/§9 were softened to + "no profile/friends/stats persisted". Guest-row GC is a logged TODO (TODO-3). + - **Push = in-process `Publisher` + backend gRPC listener** (interview): a new + `internal/notify` hub (a `Publisher` interface defaulting to `Nop`, installed on + `game`/`social`/`lobby` via `SetNotifier` during boot — additive, existing tests + unchanged) is drained by a new backend gRPC server (`internal/pushgrpc`, + `BACKEND_GRPC_ADDR` default `:9090`) serving `Push.Subscribe`. Emission lives in + `game.commit` (so robot-driver and timeout-sweep moves emit `your_turn`/ + `opponent_moved` too — the background sources a handler-only design would miss), + `social` (`chat_message`/`nudge`) and the matchmaker (`match_found`). Event + payloads are FlatBuffers-encoded **in the backend** (it imports `pkg/fbs`); the + gateway forwards them verbatim. Revoke/session-invalidation and cursor-resume are + **deferred** (single-instance MVP). + - **Edge envelope = minimal, token in header** (interview): the `Gateway` Connect + service is `Execute(message_type, payload, request_id)` + `Subscribe`; the + session token rides in `Authorization: Bearer`; auth ops are unauthenticated and + return the token in the FlatBuffers `Session`. Domain outcomes ride back in the + `ExecuteResponse.result_code` (HTTP 200); only edge failures (rate limit, missing + session, unknown type, internal) are Connect error codes. No Ed25519/signing + (the galaxy donor's crypto stack was dropped, per §3). + - **Admin = gateway validates Basic-Auth** (interview): the gateway checks + `GATEWAY_ADMIN_USER`/`_PASSWORD` and reverse-proxies to backend + `/api/v1/admin/*`; the backend admin surface is a single `ping` until Stage 9. + - **Rate-limit = 2 dimensions, 3 classes** (interview): public per-IP (30/min, + burst 10), authenticated per-user (120/min, burst 40), admin per-IP (60/min, + burst 20), plus an email-code per-IP sub-limit (5/10 min); token bucket + (`golang.org/x/time/rate`) with a lazy stale-bucket sweep. + - **Email-as-login** (discharges the Stage 4 deferral): `account.EmailService` + gained `RequestLoginCode`/`LoginWithCode`, reusing the confirm-code mechanism but + provisioning-or-finding the account by email identity (it does **not** refuse an + already-confirmed address — that is the returning user). + - **CI**: both Go workflows gained `gateway/**` (and `pkg/**` where backend depends + on it) path filters and now build/vet/test `./backend/... ./pkg/... ./gateway/...` + (unit) — integration stays `./backend/...` (the only module with tagged tests). + The solver clone + `BACKEND_DICT_DIR` steps are unchanged. + ## Deferred TODOs (cross-stage) - **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable, @@ -375,3 +436,8 @@ Open details: deployment target/host; dashboards; load expectations. dynamic-reload mechanism (Stage 9) — keep the `BACKEND_DICT_DIR` directory as the runtime contract: a new `.dawg` appears in it and is loaded with `dawg.Load`. +- **TODO-3 — garbage-collect abandoned guest accounts.** Stage 6 makes a guest a + durable `accounts` row (no identity, `is_guest`), so an ephemeral guest leaves a + row behind. Add a periodic reaper (or a finished-and-idle sweep) that deletes + guest accounts with no active games once their last session is gone; the + `ON DELETE CASCADE` foreign keys clean up the dependent rows. diff --git a/README.md b/README.md index ce4b719..c8dc1ca 100644 --- a/README.md +++ b/README.md @@ -29,15 +29,18 @@ supports English Scrabble, Russian Scrabble and Эрудит. ## Build & test ```sh -go build ./backend/... # per module (the workspace spans several modules) -go vet ./backend/... -gofmt -l . # must print nothing -go test -count=1 ./backend/... # unit tests -go test -tags=integration -count=1 -p=1 ./backend/... # + Postgres (needs Docker) +go build ./backend/... ./pkg/... ./gateway/... # per module (the workspace spans several) +go vet ./backend/... ./pkg/... ./gateway/... +gofmt -l . # must print nothing +go test -count=1 ./backend/... ./pkg/... ./gateway/... # unit tests +go test -tags=integration -count=1 -p=1 ./backend/... # + Postgres (needs Docker) ``` The `integration`-tagged tests start a throwaway `postgres:17-alpine` container -via testcontainers-go and require a reachable Docker daemon. +via testcontainers-go and require a reachable Docker daemon; they live in the +`backend` module. The wire contracts in `pkg` and the Connect edge in `gateway` +have committed generated code (regenerate dev-time with `make -C pkg gen` / +`make -C gateway gen`). ## Run the backend locally @@ -47,7 +50,17 @@ migrations at startup: ```sh docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \ - go run ./backend/cmd/backend # serves /healthz and /readyz on :8080 + go run ./backend/cmd/backend # HTTP API + probes on :8080, push gRPC on :9090 +``` + +## Run the gateway locally + +The gateway is the public edge; point it at a running backend: + +```sh +GATEWAY_BACKEND_HTTP_URL=http://localhost:8080 \ +GATEWAY_BACKEND_GRPC_ADDR=localhost:9090 \ + go run ./gateway/cmd/gateway # Connect/h2c edge on :8081 ``` Key environment: `BACKEND_HTTP_ADDR` (default `:8080`), `BACKEND_LOG_LEVEL` diff --git a/backend/README.md b/backend/README.md index fe22d78..4dd05ca 100644 --- a/backend/README.md +++ b/backend/README.md @@ -56,6 +56,17 @@ state. The matchmaker now substitutes a pooled robot after a 10-second wait and exposes `Poll` so a waiting player can collect the started game (the live match-found notification arrives with the `gateway`). +Stage 6 opens the backend to the edge. The route groups gain their first +handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under +`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a +slice of authenticated `/api/v1/user` operations (profile, submit play, game +state, lobby enqueue/poll, chat). A new `internal/notify` hub feeds a second +listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming +live events (your-turn, opponent-moved, chat, nudge, match-found) to the gateway. +Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row +with no identity, excluded from statistics. The shared wire contracts live in the +sibling [`../pkg`](../pkg) module. + ## Package layout ``` @@ -80,7 +91,8 @@ internal/robot/ # human-like robot opponent: account pool, seed-derived str | Variable | Default | Notes | | --- | --- | --- | -| `BACKEND_HTTP_ADDR` | `:8080` | HTTP listen address. | +| `BACKEND_HTTP_ADDR` | `:8080` | HTTP (REST) listen address. | +| `BACKEND_GRPC_ADDR` | `:9090` | gRPC listen address for the live-event push stream to the gateway. | | `BACKEND_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error`. | | `BACKEND_POSTGRES_DSN` | — | **Required.** pgx/libpq URL; must pin `search_path=backend`. | | `BACKEND_POSTGRES_MAX_OPEN_CONNS` | `25` | Pool max open connections. | diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 44cee3d..05dd45a 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -22,7 +22,9 @@ import ( "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/lobby" + "scrabble/backend/internal/notify" "scrabble/backend/internal/postgres" + "scrabble/backend/internal/pushgrpc" "scrabble/backend/internal/robot" "scrabble/backend/internal/server" "scrabble/backend/internal/session" @@ -58,6 +60,12 @@ func main() { // turn-timeout sweeper), the robot opponent (pool + move driver) and the // matchmaking reaper, HTTP server — and blocks until ctx is cancelled. func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { + // A cancellable child context so the first server (or signal) to stop tears + // the rest down — the HTTP and gRPC listeners and every background worker + // share it. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + tel, err := telemetry.New(ctx, cfg.Telemetry) if err != nil { return fmt.Errorf("init telemetry: %w", err) @@ -99,8 +107,14 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { } logger.Info("session cache warmed") + // The in-process live-event hub fans domain intents out to the gRPC push + // stream. It is installed on every emitting service before any background + // worker starts so robot moves and timeout sweeps also emit. + hub := notify.NewHub(0) + accounts := account.NewStore(db) games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger) + games.SetNotifier(hub) go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval) logger.Info("game turn-timeout sweeper started", zap.Duration("interval", cfg.Game.TimeoutSweepInterval)) @@ -111,6 +125,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { mailer := newMailer(cfg.SMTP, logger) emails := account.NewEmailService(accounts, mailer) socialSvc := social.NewService(social.NewStore(db), accounts, games) + socialSvc.SetNotifier(hub) // Stage 5 robot opponent: provision its durable account pool (a hard startup // dependency, like the dictionaries) and start its move driver. The matchmaker @@ -123,6 +138,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval)) matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, logger) + matchmaker.SetNotifier(hub) go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval) invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc) logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait)) @@ -132,12 +148,28 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { DB: db, PingTimeout: cfg.Postgres.OperationTimeout, SessionsReady: sessions.Ready, + Sessions: sessions, + Accounts: accounts, + Games: games, Social: socialSvc, Matchmaker: matchmaker, Invitations: invitations, Emails: emails, }) - return srv.Run(ctx) + pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger) + + // Run the HTTP and gRPC push listeners together; the first to stop (a listen + // error, or ctx cancellation on signal) tears down the other through cancel. + logger.Info("servers starting", + zap.String("http_addr", cfg.HTTPAddr), + zap.String("grpc_addr", cfg.GRPCAddr)) + errc := make(chan error, 2) + go func() { errc <- pushSrv.Run(ctx) }() + go func() { errc <- srv.Run(ctx) }() + err = <-errc + cancel() + <-errc + return err } // newMailer builds the confirm-code mailer: an SMTP relay when a host is diff --git a/backend/go.mod b/backend/go.mod index 131119d..bb0a4fc 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -54,6 +54,7 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/flatbuffers v23.5.26+incompatible github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgconn v1.14.3 // indirect github.com/jackc/pgio v1.0.0 // indirect @@ -108,7 +109,9 @@ require ( golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/xurls/v2 v2.6.0 + scrabble/pkg v0.0.0 ) diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index df418a8..48b38ad 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -1,6 +1,8 @@ // Package account owns durable internal accounts and their platform/email // identities. First contact from a platform auto-provisions an account bound to -// that identity; guests are session-only and never reach this package. +// that identity. An ephemeral guest is also a durable account row (the sessions +// and game_players foreign keys both require one) but carries no identity and is +// flagged is_guest, which excludes it from statistics, friends and history. package account import ( @@ -52,8 +54,11 @@ type Account struct { HintBalance int BlockChat bool BlockFriendRequests bool - CreatedAt time.Time - UpdatedAt time.Time + // IsGuest marks an ephemeral guest account: a durable row with no identity, + // excluded from statistics, friends and history. + IsGuest bool + CreatedAt time.Time + UpdatedAt time.Time } // Store is the Postgres-backed query surface for accounts and identities. @@ -176,6 +181,30 @@ func (s *Store) create(ctx context.Context, kind, externalID string) (Account, e return created, nil } +// guestDisplayName is the display name stamped on a freshly provisioned guest. +const guestDisplayName = "Guest" + +// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying +// no identity, flagged is_guest, so it can hold a session and a game seat (both +// foreign-key the accounts table) while being excluded from statistics, friends +// and history. Guests are not reused — each bootstrap mints a new account. +func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) { + accountID, err := uuid.NewV7() + if err != nil { + return Account{}, fmt.Errorf("account: new guest id: %w", err) + } + stmt := table.Accounts. + INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest). + VALUES(accountID, guestDisplayName, true). + RETURNING(table.Accounts.AllColumns) + + var row model.Accounts + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + return Account{}, fmt.Errorf("account: provision guest: %w", err) + } + return modelToAccount(row), nil +} + // SpendHint atomically decrements the account's hint wallet by one, returning // true when a hint was spent and false when the balance was already empty. The // guarded UPDATE keeps it safe under concurrent spends across the player's games. @@ -210,6 +239,7 @@ func modelToAccount(row model.Accounts) Account { HintBalance: int(row.HintBalance), BlockChat: row.BlockChat, BlockFriendRequests: row.BlockFriendRequests, + IsGuest: row.IsGuest, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 975354d..f113d2c 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -126,6 +126,72 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema return s.store.GetByID(ctx, accountID) } +// RequestLoginCode issues a login confirm-code to the account that owns email, +// provisioning a fresh (unconfirmed) durable account when the email is new. It is +// the unauthenticated email-login entry point (Stage 6) and, unlike RequestCode, +// does not refuse an already-confirmed email — that is the ordinary returning-user +// login. The code is mailed to the address, so only its real owner can complete +// the login. It returns the target account id for the subsequent LoginWithCode. +func (s *EmailService) RequestLoginCode(ctx context.Context, email string) (uuid.UUID, error) { + addr, err := normalizeEmail(email) + if err != nil { + return uuid.UUID{}, err + } + acc, err := s.store.ProvisionByIdentity(ctx, KindEmail, addr) + if err != nil { + return uuid.UUID{}, err + } + code, hash, err := generateCode() + if err != nil { + return uuid.UUID{}, err + } + if err := s.store.replacePendingConfirmation(ctx, acc.ID, addr, hash, s.now().Add(emailCodeTTL)); err != nil { + return uuid.UUID{}, err + } + subject := "Your Scrabble login code" + body := fmt.Sprintf("Your login code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute)) + if err := s.mailer.Send(ctx, addr, subject, body); err != nil { + return uuid.UUID{}, err + } + return acc.ID, nil +} + +// LoginWithCode verifies a login code for email and returns the owning account, +// marking the email identity confirmed on first success (idempotent for a +// returning user). It mirrors ConfirmCode's checks but updates the existing +// identity rather than inserting one, since RequestLoginCode already provisioned +// it. It returns ErrNotFound when no account owns the email. +func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (Account, error) { + addr, err := normalizeEmail(email) + if err != nil { + return Account{}, err + } + acc, err := s.store.findByIdentity(ctx, KindEmail, addr) + if err != nil { + return Account{}, err + } + conf, err := s.store.latestPendingConfirmation(ctx, acc.ID, addr) + if err != nil { + return Account{}, err + } + if s.now().After(conf.expiresAt) { + return Account{}, ErrCodeExpired + } + if conf.attempts >= emailCodeMaxAttempts { + return Account{}, ErrTooManyAttempts + } + if hashCode(code) != conf.codeHash { + if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil { + return Account{}, err + } + return Account{}, ErrCodeMismatch + } + if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil { + return Account{}, err + } + return s.store.GetByID(ctx, acc.ID) +} + // emailConfirmation is a pending confirm-code row in domain form. type emailConfirmation struct { id uuid.UUID @@ -252,6 +318,34 @@ func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accoun return nil } +// confirmEmailLogin consumes the login code and marks the existing email +// identity confirmed, inside one transaction. The identity already exists (a +// login provisioned it), so this updates rather than inserts and is idempotent +// for a returning user whose identity is already confirmed. +func (s *Store) confirmEmailLogin(ctx context.Context, confirmationID, accountID uuid.UUID, email string, now time.Time) error { + return withTx(ctx, s.db, func(tx *sql.Tx) error { + upd := table.EmailConfirmations. + UPDATE(table.EmailConfirmations.ConsumedAt). + SET(postgres.TimestampzT(now)). + WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID))) + if _, err := upd.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("consume login code: %w", err) + } + confirm := table.Identities. + UPDATE(table.Identities.Confirmed). + SET(postgres.Bool(true)). + WHERE( + table.Identities.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Identities.Kind.EQ(postgres.String(KindEmail))). + AND(table.Identities.ExternalID.EQ(postgres.String(email))), + ) + if _, err := confirm.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("confirm email identity: %w", err) + } + return nil + }) +} + // normalizeEmail parses and lower-cases an email address, or returns ErrInvalidEmail. func normalizeEmail(email string) (string, error) { addr, err := mail.ParseAddress(strings.TrimSpace(email)) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 53e1e51..739410a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -20,6 +20,9 @@ import ( type Config struct { // HTTPAddr is the listen address of the HTTP listener (host:port). HTTPAddr string + // GRPCAddr is the listen address of the gRPC push listener (host:port) that + // streams live events to the gateway. + GRPCAddr string // LogLevel is the zap log level: "debug", "info", "warn" or "error". LogLevel string // Postgres configures the primary database pool. @@ -40,6 +43,7 @@ type Config struct { // Defaults applied when the corresponding environment variable is unset. const ( defaultHTTPAddr = ":8080" + defaultGRPCAddr = ":9090" defaultLogLevel = "info" ) @@ -100,6 +104,7 @@ func Load() (Config, error) { c := Config{ HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), + GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr), LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel), Postgres: pg, Telemetry: tel, @@ -124,6 +129,9 @@ func (c Config) validate() error { if c.HTTPAddr == "" { return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty") } + if c.GRPCAddr == "" { + return fmt.Errorf("config: BACKEND_GRPC_ADDR must not be empty") + } if err := c.Postgres.Validate(); err != nil { return fmt.Errorf("config: %w (set BACKEND_POSTGRES_DSN)", err) } diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index a1394fa..62fd3a4 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -15,6 +15,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/engine" + "scrabble/backend/internal/notify" ) // Service is the game domain: it drives the engine over a single match, persists @@ -31,6 +32,7 @@ type Service struct { version string clock func() time.Time rng func() int64 + pub notify.Publisher log *zap.Logger } @@ -48,10 +50,23 @@ func NewService(store *Store, accounts *account.Store, registry *engine.Registry version: cfg.DictVersion, clock: clock, rng: randomSeed, + pub: notify.Nop{}, log: log, } } +// SetNotifier installs the live-event publisher. It must be called during +// startup wiring, before the service serves traffic or the sweeper runs; the +// default is notify.Nop (no live events). The game service emits your_turn and +// opponent_moved after every committed move, whatever the source (a player's +// request, the robot driver or the timeout sweeper, which all funnel through +// commit). +func (svc *Service) SetNotifier(p notify.Publisher) { + if p != nil { + svc.pub = p + } +} + // Create starts and persists a new game seating the given accounts in turn order // (seat 0 first), deals the racks, and warms the live-game cache. It validates // the player count (2–4), the move clock, the hint allowance and that every seat @@ -239,7 +254,12 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game c.endReason = "timeout" } c.winner = g.Result().Winner - c.stats = buildStats(g, seats) + statSeats, err := svc.nonGuestSeats(ctx, seats) + if err != nil { + svc.cache.remove(gameID) + return Game{}, err + } + c.stats = buildStats(g, statSeats) } if err := svc.store.CommitMove(ctx, c); err != nil { svc.cache.remove(gameID) @@ -248,7 +268,43 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game if c.finished { svc.cache.remove(gameID) } - return svc.store.GetGame(ctx, gameID) + post, err := svc.store.GetGame(ctx, gameID) + if err != nil { + return Game{}, err + } + svc.emitMove(post, rec) + return post, nil +} + +// emitMove publishes the live events for a just-committed move: opponent_moved to +// every seat other than the actor, and your_turn to the next mover while the game +// is still active. Delivery is best-effort (notify.Publisher never blocks). +func (svc *Service) emitMove(post Game, rec engine.MoveRecord) { + intents := make([]notify.Intent, 0, len(post.Seats)+1) + for _, s := range post.Seats { + if s.Seat == rec.Player { + continue + } + intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total)) + } + if post.Status == StatusActive { + if next, ok := seatAccount(post.Seats, post.ToMove); ok { + deadline := post.TurnStartedAt.Add(post.TurnTimeout) + intents = append(intents, notify.YourTurn(next, post.ID, deadline)) + } + } + svc.pub.Publish(intents...) +} + +// seatAccount returns the account seated at the given seat index, or false when +// no seat matches (the slice is not assumed to be ordered by seat). +func seatAccount(seats []Seat, seat int) (uuid.UUID, bool) { + for _, s := range seats { + if s.Seat == seat { + return s.AccountID, true + } + } + return uuid.UUID{}, false } // timeoutGame auto-resigns the to-move player of an overdue game. It re-checks, @@ -633,6 +689,24 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta { return out } +// nonGuestSeats filters out guest seats so the finish-time statistics are +// recomputed for durable non-guest accounts only — guests never accrue +// statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish. +func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, error) { + out := make([]Seat, 0, len(seats)) + for _, s := range seats { + acc, err := svc.accounts.GetByID(ctx, s.AccountID) + if err != nil { + return nil, err + } + if acc.IsGuest { + continue + } + out = append(out, s) + } + return out, nil +} + // seatNames resolves each seat's display name for GCG export. func (svc *Service) seatNames(ctx context.Context, g Game) []string { names := make([]string, g.Players) diff --git a/backend/internal/inttest/stage6_test.go b/backend/internal/inttest/stage6_test.go new file mode 100644 index 0000000..bade281 --- /dev/null +++ b/backend/internal/inttest/stage6_test.go @@ -0,0 +1,130 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" +) + +// provisionGuest creates a fresh ephemeral guest account and returns its id. +func provisionGuest(t *testing.T) uuid.UUID { + t.Helper() + acc, err := account.NewStore(testDB).ProvisionGuest(context.Background()) + if err != nil { + t.Fatalf("provision guest: %v", err) + } + if !acc.IsGuest { + t.Fatalf("provisioned account %s is not flagged guest", acc.ID) + } + return acc.ID +} + +// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game +// against a robot to a natural end and checks the guest holds a seat (the +// game_players foreign key is satisfied) yet accrues no statistics, while the +// durable robot opponent does. +func TestGuestAutoMatchLeavesNoStats(t *testing.T) { + ctx := context.Background() + svc := newGameService() + robots := newRobotService(t, svc) + if err := robots.EnsurePool(ctx); err != nil { + t.Fatalf("ensure pool: %v", err) + } + robotID, err := robots.Pick() + if err != nil { + t.Fatalf("pick: %v", err) + } + guest := provisionGuest(t) + seed := openingSeed(t) + + g, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID}, + TurnTimeout: 24 * time.Hour, Seed: seed, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + const robotSeat = 1 // seats = [guest, robot] + + finished := false + for i := 0; i < 400 && !finished; i++ { + _, toMove, status, err := svc.Participants(ctx, g.ID) + if err != nil { + t.Fatalf("participants: %v", err) + } + if status != game.StatusActive { + finished = true + break + } + if toMove == robotSeat { + setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour)) + robots.Drive(ctx, daytime) + continue + } + playHuman(t, ctx, svc, g.ID, guest) + } + if !finished { + t.Fatal("guest game did not finish within the move budget") + } + + if _, _, _, _, _, ok := readStats(t, guest); ok { + t.Error("a guest must not accrue a statistics row") + } + if _, _, _, _, _, ok := readStats(t, robotID); !ok { + t.Error("the durable robot opponent should have a statistics row") + } +} + +// TestEmailLoginFlow covers the Stage 6 email-as-login path: a code is mailed to +// a new address, verifying it provisions and returns the owning account, and a +// second login for the same address resolves to that same account (a returning +// user), with the identity confirmed. +func TestEmailLoginFlow(t *testing.T) { + ctx := context.Background() + mailer := &capturingMailer{} + svc := account.NewEmailService(account.NewStore(testDB), mailer) + email := "login-" + uuid.NewString() + "@example.com" + + accountID, err := svc.RequestLoginCode(ctx, email) + if err != nil { + t.Fatalf("request login code: %v", err) + } + code := sixDigit.FindString(mailer.lastBody) + if code == "" { + t.Fatalf("no code in mail body %q", mailer.lastBody) + } + + acc, err := svc.LoginWithCode(ctx, email, code) + if err != nil { + t.Fatalf("login with code: %v", err) + } + if acc.ID != accountID { + t.Errorf("login account = %s, want %s", acc.ID, accountID) + } + if acc.IsGuest { + t.Error("an email account must be durable, not a guest") + } + if !identityConfirmed(t, account.KindEmail, email) { + t.Error("the email identity must be confirmed after login") + } + + // A second login for the same email is the returning user: same account. + if _, err := svc.RequestLoginCode(ctx, email); err != nil { + t.Fatalf("second request: %v", err) + } + acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)) + if err != nil { + t.Fatalf("second login: %v", err) + } + if acc2.ID != accountID { + t.Errorf("returning login account = %s, want %s", acc2.ID, accountID) + } +} diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index b1043c6..649e98f 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -11,6 +11,7 @@ import ( "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/notify" ) // Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs @@ -30,6 +31,7 @@ type Matchmaker struct { robots RobotProvider waitDelay time.Duration clock func() time.Time + pub notify.Publisher log *zap.Logger mu sync.Mutex @@ -51,6 +53,7 @@ func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Durat robots: robots, waitDelay: waitDelay, clock: func() time.Time { return time.Now().UTC() }, + pub: notify.Nop{}, log: log, queues: make(map[engine.Variant][]uuid.UUID), queued: make(map[uuid.UUID]engine.Variant), @@ -60,6 +63,26 @@ func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Durat } } +// SetNotifier installs the live-event publisher used to push match_found to the +// seated players when a pairing or robot substitution starts a game. It must be +// called during startup wiring, before the reaper runs; the default is +// notify.Nop (no live events; waiters still discover the game via Poll). +func (m *Matchmaker) SetNotifier(p notify.Publisher) { + if p != nil { + m.pub = p + } +} + +// emitMatchFound pushes match_found to every seat of a freshly started game. +// Emitting to a robot seat is harmless (no client subscription exists for it). +func (m *Matchmaker) emitMatchFound(g game.Game) { + intents := make([]notify.Intent, 0, len(g.Seats)) + for _, s := range g.Seats { + intents = append(intents, notify.MatchFound(s.AccountID, g.ID)) + } + m.pub.Publish(intents...) +} + // EnqueueResult reports the outcome of joining the pool: either a started game or a // queued ticket awaiting an opponent. type EnqueueResult struct { @@ -102,6 +125,7 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e m.mu.Lock() m.results[opponent] = g m.mu.Unlock() + m.emitMatchFound(g) return EnqueueResult{Matched: true, Game: g}, nil } @@ -197,6 +221,7 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { m.mu.Lock() m.results[s.human] = g m.mu.Unlock() + m.emitMatchFound(g) } } diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go new file mode 100644 index 0000000..8c381ec --- /dev/null +++ b/backend/internal/notify/events.go @@ -0,0 +1,92 @@ +package notify + +import ( + "time" + + flatbuffers "github.com/google/flatbuffers/go" + "github.com/google/uuid" + + fb "scrabble/pkg/fbs/scrabblefb" +) + +// The constructors below build one Intent per live event, FlatBuffers-encoding +// the payload with the shared scrabblefb schema. Keeping the encoding here lets +// the game/social/lobby services emit events without importing the wire schema. + +// YourTurn announces to userID that it is their turn in game gameID, with the +// turn's nominal deadline. +func YourTurn(userID, gameID uuid.UUID, deadline time.Time) Intent { + b := flatbuffers.NewBuilder(64) + gid := b.CreateString(gameID.String()) + fb.YourTurnEventStart(b) + fb.YourTurnEventAddGameId(b, gid) + fb.YourTurnEventAddDeadlineUnix(b, deadline.Unix()) + b.Finish(fb.YourTurnEventEnd(b)) + return Intent{UserID: userID, Kind: KindYourTurn, Payload: b.FinishedBytes(), EventID: eventID()} +} + +// OpponentMoved tells userID that seat just committed a move in game gameID, +// summarising it (the client refetches the full state). +func OpponentMoved(userID, gameID uuid.UUID, seat int, action string, score, total int) Intent { + b := flatbuffers.NewBuilder(64) + gid := b.CreateString(gameID.String()) + act := b.CreateString(action) + fb.OpponentMovedEventStart(b) + fb.OpponentMovedEventAddGameId(b, gid) + fb.OpponentMovedEventAddSeat(b, int32(seat)) + fb.OpponentMovedEventAddAction(b, act) + fb.OpponentMovedEventAddScore(b, int32(score)) + fb.OpponentMovedEventAddTotal(b, int32(total)) + b.Finish(fb.OpponentMovedEventEnd(b)) + return Intent{UserID: userID, Kind: KindOpponentMoved, Payload: b.FinishedBytes(), EventID: eventID()} +} + +// ChatMessage delivers a stored chat message (or nudge) to userID. +func ChatMessage(userID, gameID, senderID uuid.UUID, id, kind, body string, createdAt time.Time) Intent { + b := flatbuffers.NewBuilder(128) + idOff := b.CreateString(id) + gid := b.CreateString(gameID.String()) + sid := b.CreateString(senderID.String()) + kindOff := b.CreateString(kind) + bodyOff := b.CreateString(body) + fb.ChatMessageStart(b) + fb.ChatMessageAddId(b, idOff) + fb.ChatMessageAddGameId(b, gid) + fb.ChatMessageAddSenderId(b, sid) + fb.ChatMessageAddKind(b, kindOff) + fb.ChatMessageAddBody(b, bodyOff) + fb.ChatMessageAddCreatedAtUnix(b, createdAt.Unix()) + b.Finish(fb.ChatMessageEnd(b)) + return Intent{UserID: userID, Kind: KindChatMessage, Payload: b.FinishedBytes(), EventID: eventID()} +} + +// Nudge tells userID that fromUserID nudged them in game gameID. +func Nudge(userID, gameID, fromUserID uuid.UUID) Intent { + b := flatbuffers.NewBuilder(64) + gid := b.CreateString(gameID.String()) + from := b.CreateString(fromUserID.String()) + fb.NudgeEventStart(b) + fb.NudgeEventAddGameId(b, gid) + fb.NudgeEventAddFromUserId(b, from) + b.Finish(fb.NudgeEventEnd(b)) + return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()} +} + +// MatchFound tells userID that game gameID, which they are seated in, has +// started (an auto-match pairing or a robot substitution). +func MatchFound(userID, gameID uuid.UUID) Intent { + b := flatbuffers.NewBuilder(64) + gid := b.CreateString(gameID.String()) + fb.MatchFoundEventStart(b) + fb.MatchFoundEventAddGameId(b, gid) + b.Finish(fb.MatchFoundEventEnd(b)) + return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()} +} + +// eventID returns a best-effort correlation id for one emitted event. +func eventID() string { + if id, err := uuid.NewV7(); err == nil { + return id.String() + } + return "" +} diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go new file mode 100644 index 0000000..2f6fd20 --- /dev/null +++ b/backend/internal/notify/notify.go @@ -0,0 +1,110 @@ +// Package notify is the backend's in-process live-event seam. Domain services +// publish Intents after a successful commit; the gRPC push server (internal +// /pushgrpc) subscribes to the hub and streams them to the gateway, which fans +// them out to clients (docs/ARCHITECTURE.md §10). Event payloads are +// FlatBuffers-encoded by the typed constructors in events.go, so the domain +// services stay free of the wire schema and only depend on this package. +// +// Publishing is best-effort and non-blocking: a live event is a convenience, not +// a correctness requirement, so a slow or absent subscriber never blocks a game +// transition. The default Publisher is Nop, which keeps every domain service (and +// its tests) runnable without a live channel. +package notify + +import ( + "sync" + + "github.com/google/uuid" +) + +// Notification kinds — the catalog in docs/ARCHITECTURE.md §10. +const ( + KindYourTurn = "your_turn" + KindOpponentMoved = "opponent_moved" + KindChatMessage = "chat_message" + KindNudge = "nudge" + KindMatchFound = "match_found" +) + +// Intent is one live event destined for a single user. Payload is the +// FlatBuffers-encoded body (a scrabblefb.* table) that the gateway forwards +// verbatim to the client; EventID is a correlation id carried through unchanged. +type Intent struct { + UserID uuid.UUID + Kind string + Payload []byte + EventID string +} + +// Publisher accepts live-event intents. Implementations must be safe for +// concurrent use and must not block the caller. +type Publisher interface { + Publish(intents ...Intent) +} + +// Nop is the default Publisher: it discards every intent. +type Nop struct{} + +// Publish discards the intents. +func (Nop) Publish(...Intent) {} + +// Hub is the in-process fan-in/fan-out between the domain publishers and the +// push subscribers (the gRPC stream). It is safe for concurrent use. +type Hub struct { + mu sync.Mutex + subs map[int]chan Intent + nextID int + bufSize int +} + +// defaultBuffer is the per-subscriber queue depth used when NewHub is given a +// non-positive size. +const defaultBuffer = 256 + +// NewHub returns a Hub whose per-subscriber buffer holds bufSize intents before +// dropping (a slow subscriber never blocks a publisher). +func NewHub(bufSize int) *Hub { + if bufSize <= 0 { + bufSize = defaultBuffer + } + return &Hub{subs: make(map[int]chan Intent), bufSize: bufSize} +} + +// Publish delivers each intent to every current subscriber, dropping it for any +// subscriber whose buffer is full (best-effort live delivery). +func (h *Hub) Publish(intents ...Intent) { + h.mu.Lock() + defer h.mu.Unlock() + for _, in := range intents { + for _, ch := range h.subs { + select { + case ch <- in: + default: + } + } + } +} + +// Subscribe registers a new subscriber and returns its intent channel and an +// unsubscribe func that closes the channel. The caller reads the channel until +// it is closed or its own context ends, then calls unsubscribe. +func (h *Hub) Subscribe() (<-chan Intent, func()) { + h.mu.Lock() + defer h.mu.Unlock() + id := h.nextID + h.nextID++ + ch := make(chan Intent, h.bufSize) + h.subs[id] = ch + return ch, func() { h.unsubscribe(id) } +} + +// unsubscribe removes and closes the subscriber's channel. 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 ch, ok := h.subs[id]; ok { + delete(h.subs, id) + close(ch) + } +} diff --git a/backend/internal/notify/notify_test.go b/backend/internal/notify/notify_test.go new file mode 100644 index 0000000..c0635b7 --- /dev/null +++ b/backend/internal/notify/notify_test.go @@ -0,0 +1,100 @@ +package notify_test + +import ( + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/notify" + fb "scrabble/pkg/fbs/scrabblefb" +) + +func TestHubDeliversToSubscriber(t *testing.T) { + h := notify.NewHub(4) + ch, cancel := h.Subscribe() + defer cancel() + + want := notify.Intent{UserID: uuid.New(), Kind: notify.KindYourTurn, Payload: []byte{1, 2, 3}} + h.Publish(want) + + select { + case got := <-ch: + if got.Kind != want.Kind || got.UserID != want.UserID { + t.Fatalf("delivered %+v, want %+v", got, want) + } + case <-time.After(time.Second): + t.Fatal("no delivery within timeout") + } +} + +func TestHubDropsWhenSubscriberBufferFull(t *testing.T) { + h := notify.NewHub(1) + ch, cancel := h.Subscribe() + defer cancel() + + in := notify.Intent{UserID: uuid.New(), Kind: notify.KindNudge} + // Buffer holds one; the second and third are dropped, and Publish must not block. + h.Publish(in, in, in) + + if got := len(ch); got != 1 { + t.Fatalf("buffered %d intents, want 1 (rest dropped)", got) + } +} + +func TestHubUnsubscribeClosesChannel(t *testing.T) { + h := notify.NewHub(2) + ch, cancel := h.Subscribe() + cancel() + + if _, ok := <-ch; ok { + t.Fatal("channel should be closed after unsubscribe") + } + // Publishing after unsubscribe must be safe (no panic, no delivery). + h.Publish(notify.Intent{Kind: notify.KindMatchFound}) +} + +func TestNopPublisherDiscards(t *testing.T) { + var p notify.Publisher = notify.Nop{} + p.Publish(notify.Intent{Kind: notify.KindYourTurn}) // must not panic +} + +func TestYourTurnPayloadRoundTrips(t *testing.T) { + uid, gid := uuid.New(), uuid.New() + in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0)) + if in.UserID != uid || in.Kind != notify.KindYourTurn || in.EventID == "" { + t.Fatalf("intent metadata wrong: %+v", in) + } + ev := fb.GetRootAsYourTurnEvent(in.Payload, 0) + if got := string(ev.GameId()); got != gid.String() { + t.Fatalf("game id = %q, want %q", got, gid) + } + if got := ev.DeadlineUnix(); got != 1717000000 { + t.Fatalf("deadline = %d, want 1717000000", got) + } +} + +func TestOpponentMovedPayloadRoundTrips(t *testing.T) { + uid, gid := uuid.New(), uuid.New() + in := notify.OpponentMoved(uid, gid, 1, "play", 24, 130) + if in.Kind != notify.KindOpponentMoved { + t.Fatalf("kind = %q", in.Kind) + } + ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0) + if string(ev.GameId()) != gid.String() || ev.Seat() != 1 || string(ev.Action()) != "play" || ev.Score() != 24 || ev.Total() != 130 { + t.Fatalf("decoded wrong: game=%q seat=%d action=%q score=%d total=%d", + ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total()) + } +} + +func TestChatMessagePayloadRoundTrips(t *testing.T) { + uid, gid, sid := uuid.New(), uuid.New(), uuid.New() + in := notify.ChatMessage(uid, gid, sid, "msg-1", "message", "hi", time.Unix(1717000001, 0)) + if in.Kind != notify.KindChatMessage { + t.Fatalf("kind = %q", in.Kind) + } + ev := fb.GetRootAsChatMessage(in.Payload, 0) + if string(ev.Id()) != "msg-1" || string(ev.SenderId()) != sid.String() || string(ev.Body()) != "hi" || ev.CreatedAtUnix() != 1717000001 { + t.Fatalf("decoded wrong chat message: %+v", ev) + } +} diff --git a/backend/internal/postgres/jet/backend/model/accounts.go b/backend/internal/postgres/jet/backend/model/accounts.go index a75ea1f..31aac98 100644 --- a/backend/internal/postgres/jet/backend/model/accounts.go +++ b/backend/internal/postgres/jet/backend/model/accounts.go @@ -24,4 +24,5 @@ type Accounts struct { AwayStart time.Time AwayEnd time.Time HintBalance int32 + IsGuest bool } diff --git a/backend/internal/postgres/jet/backend/table/accounts.go b/backend/internal/postgres/jet/backend/table/accounts.go index 55e3784..a4bd060 100644 --- a/backend/internal/postgres/jet/backend/table/accounts.go +++ b/backend/internal/postgres/jet/backend/table/accounts.go @@ -28,6 +28,7 @@ type accountsTable struct { AwayStart postgres.ColumnTime AwayEnd postgres.ColumnTime HintBalance postgres.ColumnInteger + IsGuest postgres.ColumnBool AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -80,9 +81,10 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { AwayStartColumn = postgres.TimeColumn("away_start") AwayEndColumn = postgres.TimeColumn("away_end") HintBalanceColumn = postgres.IntegerColumn("hint_balance") - allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn} - mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn} - defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn} + IsGuestColumn = postgres.BoolColumn("is_guest") + allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn} + mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn} + defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn} ) return accountsTable{ @@ -100,6 +102,7 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { AwayStart: AwayStartColumn, AwayEnd: AwayEndColumn, HintBalance: HintBalanceColumn, + IsGuest: IsGuestColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/migrations/00005_guest.sql b/backend/internal/postgres/migrations/00005_guest.sql new file mode 100644 index 0000000..f0f3207 --- /dev/null +++ b/backend/internal/postgres/migrations/00005_guest.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- Stage 6 gateway edge: mark ephemeral guest accounts. A guest is a durable +-- account row -- the sessions and game_players foreign keys both require one -- +-- that carries no identity and no profile, friends, stats or history; is_guest +-- gates that exclusion (statistics recompute skips guest seats). This adds a +-- column, so the generated jet code is regenerated (cmd/jetgen). +SET search_path = backend, pg_catalog; + +ALTER TABLE accounts ADD COLUMN is_guest boolean NOT NULL DEFAULT false; + +-- +goose Down +SET search_path = backend, pg_catalog; + +ALTER TABLE accounts DROP COLUMN is_guest; diff --git a/backend/internal/pushgrpc/server.go b/backend/internal/pushgrpc/server.go new file mode 100644 index 0000000..e9a73bf --- /dev/null +++ b/backend/internal/pushgrpc/server.go @@ -0,0 +1,107 @@ +// Package pushgrpc serves the backend -> gateway live-event stream: a gRPC +// server exposing the scrabble.push.v1 Push service (docs/ARCHITECTURE.md §2). +// It bridges the in-process notify.Hub to the wire — each Subscribe stream +// drains a hub subscription and forwards every Intent as a push Event. The +// gateway opens one long-lived Subscribe at startup and fans the events out to +// its clients. +package pushgrpc + +import ( + "context" + "fmt" + "net" + + "go.uber.org/zap" + "google.golang.org/grpc" + + "scrabble/backend/internal/notify" + pushv1 "scrabble/pkg/proto/push/v1" +) + +// Service implements pushv1.PushServer over a notify.Hub. +type Service struct { + pushv1.UnimplementedPushServer + hub *notify.Hub + log *zap.Logger +} + +// NewService constructs a Service that streams the hub's intents. +func NewService(hub *notify.Hub, log *zap.Logger) *Service { + if log == nil { + log = zap.NewNop() + } + return &Service{hub: hub, log: log} +} + +// Subscribe opens a hub subscription and forwards every intent to the gateway +// until the stream's context ends (the gateway disconnected or the server is +// shutting down). It returns nil on a clean disconnect. +func (s *Service) Subscribe(req *pushv1.SubscribeRequest, stream grpc.ServerStreamingServer[pushv1.Event]) error { + ch, cancel := s.hub.Subscribe() + defer cancel() + + s.log.Info("gateway push subscription opened", zap.String("gateway_id", req.GetGatewayId())) + defer s.log.Info("gateway push subscription closed", zap.String("gateway_id", req.GetGatewayId())) + + ctx := stream.Context() + for { + select { + case <-ctx.Done(): + return nil + case in, ok := <-ch: + if !ok { + return nil + } + ev := &pushv1.Event{ + UserId: in.UserID.String(), + Kind: in.Kind, + Payload: in.Payload, + EventId: in.EventID, + } + if err := stream.Send(ev); err != nil { + return err + } + } + } +} + +// Server wraps the gRPC listener serving the Push service. Its Run mirrors the +// HTTP server's: serve until the context is cancelled, then stop gracefully. +type Server struct { + grpc *grpc.Server + addr string + log *zap.Logger +} + +// NewServer builds a gRPC server bound to addr that streams hub events. +func NewServer(addr string, hub *notify.Hub, log *zap.Logger) *Server { + if log == nil { + log = zap.NewNop() + } + gs := grpc.NewServer() + pushv1.RegisterPushServer(gs, NewService(hub, log)) + return &Server{grpc: gs, addr: addr, log: log} +} + +// Run starts the listener and blocks until ctx is cancelled, then stops the +// server gracefully. It returns the first error that is not a clean shutdown. +func (s *Server) Run(ctx context.Context) error { + lis, err := net.Listen("tcp", s.addr) + if err != nil { + return fmt.Errorf("pushgrpc: listen %s: %w", s.addr, err) + } + errc := make(chan error, 1) + go func() { + s.log.Info("push grpc listener starting", zap.String("addr", s.addr)) + errc <- s.grpc.Serve(lis) + }() + + select { + case err := <-errc: + return err + case <-ctx.Done(): + s.log.Info("push grpc listener stopping") + s.grpc.GracefulStop() + return nil + } +} diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go new file mode 100644 index 0000000..2fc4990 --- /dev/null +++ b/backend/internal/server/dto.go @@ -0,0 +1,250 @@ +package server + +import ( + "strings" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" + "scrabble/backend/internal/lobby" + "scrabble/backend/internal/social" +) + +// The JSON DTOs below are the gateway<->backend REST contract. They are explicit +// (the domain/engine structs are never serialised directly) and mirror the +// FlatBuffers edge tables (pkg/fbs) the gateway transcodes to and from. + +// sessionResponse is the credential returned by every auth endpoint. +type sessionResponse struct { + Token string `json:"token"` + UserID string `json:"user_id"` + IsGuest bool `json:"is_guest"` + DisplayName string `json:"display_name"` +} + +// okResponse is a simple success acknowledgement. +type okResponse struct { + OK bool `json:"ok"` +} + +// resolveResponse maps a session token to its account. +type resolveResponse struct { + UserID string `json:"user_id"` +} + +// profileResponse is the authenticated account's own profile. +type profileResponse 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"` +} + +// tileDTO is one placed (or to-place) tile. +type tileDTO struct { + Row int `json:"row"` + Col int `json:"col"` + Letter string `json:"letter"` + Blank bool `json:"blank"` +} + +// moveRecordDTO is a decoded move (a committed play, or a hint preview). +type moveRecordDTO struct { + Player int `json:"player"` + Action string `json:"action"` + Dir string `json:"dir"` + MainRow int `json:"main_row"` + MainCol int `json:"main_col"` + Tiles []tileDTO `json:"tiles"` + Words []string `json:"words"` + Count int `json:"count"` + Score int `json:"score"` + Total int `json:"total"` +} + +// seatDTO is one seat's public standing. +type seatDTO struct { + Seat int `json:"seat"` + AccountID string `json:"account_id"` + Score int `json:"score"` + HintsUsed int `json:"hints_used"` + IsWinner bool `json:"is_winner"` +} + +// gameDTO is the shared game summary. +type gameDTO 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 []seatDTO `json:"seats"` +} + +// moveResultDTO is the outcome of a committed move. +type moveResultDTO struct { + Move moveRecordDTO `json:"move"` + Game gameDTO `json:"game"` +} + +// stateDTO is a player's view of a game. +type stateDTO struct { + Game gameDTO `json:"game"` + Seat int `json:"seat"` + Rack []string `json:"rack"` + BagLen int `json:"bag_len"` + HintsRemaining int `json:"hints_remaining"` +} + +// matchDTO reports whether the caller has been paired into a game. +type matchDTO struct { + Matched bool `json:"matched"` + Game *gameDTO `json:"game,omitempty"` +} + +// chatDTO is one stored chat message or nudge. +type chatDTO 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"` +} + +// errorResponse is the uniform error envelope. +type errorResponse struct { + Error errorBody `json:"error"` +} + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// sessionResponseFor builds the credential payload for a minted session. +func sessionResponseFor(token string, acc account.Account) sessionResponse { + return sessionResponse{ + Token: token, + UserID: acc.ID.String(), + IsGuest: acc.IsGuest, + DisplayName: acc.DisplayName, + } +} + +// profileResponseFor projects an account into its profile DTO. +func profileResponseFor(acc account.Account) profileResponse { + return profileResponse{ + UserID: acc.ID.String(), + DisplayName: acc.DisplayName, + PreferredLanguage: acc.PreferredLanguage, + TimeZone: acc.TimeZone, + HintBalance: acc.HintBalance, + BlockChat: acc.BlockChat, + BlockFriendRequests: acc.BlockFriendRequests, + IsGuest: acc.IsGuest, + } +} + +// gameDTOFromGame projects a game.Game into its DTO. +func gameDTOFromGame(g game.Game) gameDTO { + seats := make([]seatDTO, 0, len(g.Seats)) + for _, s := range g.Seats { + seats = append(seats, seatDTO{ + Seat: s.Seat, + AccountID: s.AccountID.String(), + Score: s.Score, + HintsUsed: s.HintsUsed, + IsWinner: s.IsWinner, + }) + } + return gameDTO{ + ID: g.ID.String(), + Variant: g.Variant.String(), + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), + MoveCount: g.MoveCount, + EndReason: g.EndReason, + Seats: seats, + } +} + +// moveRecordDTOFrom projects an engine move record into its DTO. +func moveRecordDTOFrom(m engine.MoveRecord) moveRecordDTO { + tiles := make([]tileDTO, 0, len(m.Tiles)) + for _, t := range m.Tiles { + tiles = append(tiles, tileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}) + } + return moveRecordDTO{ + Player: m.Player, + Action: m.Action.String(), + Dir: m.Dir.String(), + MainRow: m.MainRow, + MainCol: m.MainCol, + Tiles: tiles, + Words: m.Words, + Count: m.Count, + Score: m.Score, + Total: m.Total, + } +} + +// moveResultDTOFrom projects a committed move result into its DTO. +func moveResultDTOFrom(r game.MoveResult) moveResultDTO { + return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)} +} + +// stateDTOFrom projects a player's state view into its DTO. +func stateDTOFrom(v game.StateView) stateDTO { + return stateDTO{ + Game: gameDTOFromGame(v.Game), + Seat: v.Seat, + Rack: v.Rack, + BagLen: v.BagLen, + HintsRemaining: v.HintsRemaining, + } +} + +// matchDTOFrom projects an enqueue/poll result into its DTO. +func matchDTOFrom(r lobby.EnqueueResult) matchDTO { + if !r.Matched { + return matchDTO{Matched: false} + } + g := gameDTOFromGame(r.Game) + return matchDTO{Matched: true, Game: &g} +} + +// chatDTOFrom projects a chat message into its DTO. +func chatDTOFrom(m social.Message) chatDTO { + return chatDTO{ + ID: m.ID.String(), + GameID: m.GameID.String(), + SenderID: m.SenderID.String(), + Kind: m.Kind, + Body: m.Body, + CreatedAtUnix: m.CreatedAt.Unix(), + } +} + +// parseDirection maps the wire direction string to an engine.Direction. +func parseDirection(s string) (engine.Direction, bool) { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "H": + return engine.Horizontal, true + case "V": + return engine.Vertical, true + default: + return 0, false + } +} diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go new file mode 100644 index 0000000..fc6f7df --- /dev/null +++ b/backend/internal/server/dto_test.go @@ -0,0 +1,113 @@ +package server + +import ( + "net/http" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" + "scrabble/backend/internal/session" + "scrabble/backend/internal/social" +) + +func TestParseDirection(t *testing.T) { + cases := map[string]struct { + in string + want engine.Direction + ok bool + }{ + "horizontal": {"H", engine.Horizontal, true}, + "vertical": {"V", engine.Vertical, true}, + "lowercase": {"h", engine.Horizontal, true}, + "trimmed": {" V ", engine.Vertical, true}, + "invalid": {"X", 0, false}, + "empty": {"", 0, false}, + "diagonal-is-not": {"D", 0, false}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, ok := parseDirection(tc.in) + if ok != tc.ok || (ok && got != tc.want) { + t.Fatalf("parseDirection(%q) = (%v, %v), want (%v, %v)", tc.in, got, ok, tc.want, tc.ok) + } + }) + } +} + +func TestStatusForError(t *testing.T) { + cases := map[string]struct { + err error + wantStatus int + wantCode string + }{ + "not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"}, + "not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"}, + "illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"}, + "email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"}, + "code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"}, + "session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"}, + "chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"}, + "unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + status, code := statusForError(tc.err) + if status != tc.wantStatus || code != tc.wantCode { + t.Fatalf("statusForError(%v) = (%d, %q), want (%d, %q)", tc.err, status, code, tc.wantStatus, tc.wantCode) + } + }) + } +} + +// context_deadline is an arbitrary unmapped error standing in for "anything +// unrecognised", which must fall through to 500/internal. +var context_deadline = errNew("boom") + +type simpleErr string + +func (e simpleErr) Error() string { return string(e) } +func errNew(s string) error { return simpleErr(s) } + +func TestGameDTOFromGame(t *testing.T) { + gid, aid := uuid.New(), uuid.New() + g := game.Game{ + ID: gid, + Variant: engine.VariantEnglish, + DictVersion: "v1", + Status: game.StatusActive, + Players: 2, + ToMove: 1, + TurnTimeout: 24 * time.Hour, + MoveCount: 3, + Seats: []game.Seat{{Seat: 0, AccountID: aid, Score: 12}}, + } + dto := gameDTOFromGame(g) + if dto.ID != gid.String() || dto.Variant != "english" || dto.ToMove != 1 || dto.TurnTimeoutSecs != 86400 { + t.Fatalf("game dto mismatch: %+v", dto) + } + if len(dto.Seats) != 1 || dto.Seats[0].AccountID != aid.String() || dto.Seats[0].Score != 12 { + t.Fatalf("seat dto mismatch: %+v", dto.Seats) + } +} + +func TestMoveRecordDTOFrom(t *testing.T) { + rec := engine.MoveRecord{ + Player: 1, + Action: engine.ActionPlay, + Dir: engine.Vertical, + MainRow: 7, + MainCol: 7, + Tiles: []engine.TileRecord{{Row: 7, Col: 7, Letter: "A", Blank: false}}, + Words: []string{"AB"}, + Score: 10, + Total: 10, + } + dto := moveRecordDTOFrom(rec) + if dto.Action != "play" || dto.Dir != "V" || dto.Score != 10 || len(dto.Tiles) != 1 || dto.Tiles[0].Letter != "A" { + t.Fatalf("move dto mismatch: %+v", dto) + } +} diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go new file mode 100644 index 0000000..de3a62d --- /dev/null +++ b/backend/internal/server/handlers.go @@ -0,0 +1,133 @@ +package server + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" + "scrabble/backend/internal/lobby" + "scrabble/backend/internal/session" + "scrabble/backend/internal/social" +) + +// registerRoutes wires the Stage 6 REST handlers onto the /api/v1 groups. The +// internal group is gateway-only (the gateway authenticates and forwards); the +// user group requires X-User-ID; the admin group is reached through the gateway's +// Basic-Auth proxy. This is the representative vertical slice — further domain +// operations follow the same pattern (PLAN.md Stage 6). +func (s *Server) registerRoutes() { + if s.sessions != nil && s.accounts != nil { + in := s.internal + in.POST("/sessions/telegram", s.handleTelegramAuth) + in.POST("/sessions/guest", s.handleGuestAuth) + in.POST("/sessions/email/request", s.handleEmailRequest) + in.POST("/sessions/email/login", s.handleEmailLogin) + in.POST("/sessions/resolve", s.handleResolveSession) + in.POST("/sessions/revoke", s.handleRevokeSession) + } + u := s.user + if s.accounts != nil { + u.GET("/profile", s.handleProfile) + } + if s.games != nil { + u.POST("/games/:id/play", s.handleSubmitPlay) + u.GET("/games/:id/state", s.handleGameState) + } + if s.matchmaker != nil { + u.POST("/lobby/enqueue", s.handleEnqueue) + u.GET("/lobby/poll", s.handlePoll) + } + if s.social != nil { + u.POST("/games/:id/chat", s.handleChatPost) + } + s.admin.GET("/ping", s.handleAdminPing) +} + +// userID returns the authenticated account id stored by RequireUserID. The user +// group always runs that middleware, so absence is a programming error. +func userID(c *gin.Context) (uuid.UUID, bool) { + return UserIDFromContext(c.Request.Context()) +} + +// gameIDParam parses the :id path parameter as a game UUID. +func gameIDParam(c *gin.Context) (uuid.UUID, bool) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return uuid.UUID{}, false + } + return id, true +} + +// clientIP returns the originating client IP the gateway forwarded in +// X-Forwarded-For (the first hop), falling back to the direct peer. +func clientIP(c *gin.Context) string { + if xff := c.GetHeader("X-Forwarded-For"); xff != "" { + if i := strings.IndexByte(xff, ','); i >= 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + return c.ClientIP() +} + +// abortBadRequest rejects a malformed request body or parameter. +func abortBadRequest(c *gin.Context, msg string) { + c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "bad_request", Message: msg}}) +} + +// abortErr maps a domain error to its HTTP status and a stable code. Server-side +// (5xx) errors are logged with the real cause and reported generically. +func (s *Server) abortErr(c *gin.Context, err error) { + status, code := statusForError(err) + msg := err.Error() + if status >= http.StatusInternalServerError { + s.log.Error("request failed", zap.String("path", c.FullPath()), zap.Error(err)) + msg = "internal error" + } + c.AbortWithStatusJSON(status, errorResponse{Error: errorBody{Code: code, Message: msg}}) +} + +// statusForError maps a known domain sentinel to an HTTP status and code, +// defaulting to 500/internal for anything unrecognised. +func statusForError(err error) (int, string) { + switch { + case errors.Is(err, game.ErrNotFound), errors.Is(err, account.ErrNotFound): + return http.StatusNotFound, "not_found" + case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant): + return http.StatusForbidden, "not_a_player" + case errors.Is(err, game.ErrNotYourTurn), errors.Is(err, social.ErrNudgeOnOwnTurn): + return http.StatusConflict, "not_your_turn" + case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive): + return http.StatusConflict, "game_finished" + case errors.Is(err, lobby.ErrAlreadyQueued): + return http.StatusConflict, "already_queued" + case errors.Is(err, game.ErrInvalidConfig): + return http.StatusBadRequest, "invalid_config" + case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft), errors.Is(err, game.ErrNoHintAvailable): + return http.StatusConflict, "hint_unavailable" + case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver): + return http.StatusUnprocessableEntity, "illegal_play" + case errors.Is(err, account.ErrEmailTaken): + return http.StatusConflict, "email_taken" + case errors.Is(err, account.ErrInvalidEmail): + return http.StatusBadRequest, "invalid_email" + case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired), + errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts): + return http.StatusUnauthorized, "code_invalid" + case errors.Is(err, session.ErrNotFound): + return http.StatusUnauthorized, "session_invalid" + case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong), + errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent), + errors.Is(err, social.ErrNudgeTooSoon): + return http.StatusUnprocessableEntity, "chat_rejected" + default: + return http.StatusInternalServerError, "internal" + } +} diff --git a/backend/internal/server/handlers_admin.go b/backend/internal/server/handlers_admin.go new file mode 100644 index 0000000..7bf5b23 --- /dev/null +++ b/backend/internal/server/handlers_admin.go @@ -0,0 +1,16 @@ +package server + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// The /api/v1/admin/* endpoints are reached through the gateway's Basic-Auth +// reverse proxy (docs/ARCHITECTURE.md §12). The backend trusts the gateway to +// have authenticated the operator; the admin surface itself (complaint review, +// dictionary versions) lands in Stage 9. handleAdminPing is the proxy target that +// proves the path end to end until then. +func (s *Server) handleAdminPing(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go new file mode 100644 index 0000000..a110ef2 --- /dev/null +++ b/backend/internal/server/handlers_auth.go @@ -0,0 +1,134 @@ +package server + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "scrabble/backend/internal/account" +) + +// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has +// already validated the originating credential (Telegram initData, an email +// code, or a guest bootstrap) and forwards the result here to provision the +// account and mint the opaque session. The backend trusts the gateway on this +// segment (docs/ARCHITECTURE.md §12). + +// telegramAuthRequest carries the platform user id the gateway extracted from a +// validated initData payload. +type telegramAuthRequest struct { + ExternalID string `json:"external_id"` +} + +// handleTelegramAuth provisions (or finds) the account bound to a Telegram +// identity and mints a session for it. +func (s *Server) handleTelegramAuth(c *gin.Context) { + var req telegramAuthRequest + if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" { + abortBadRequest(c, "external_id is required") + return + } + acc, err := s.accounts.ProvisionByIdentity(c.Request.Context(), account.KindTelegram, req.ExternalID) + if err != nil { + s.abortErr(c, err) + return + } + s.mintSession(c, acc) +} + +// handleGuestAuth provisions a fresh ephemeral guest account and mints a session. +func (s *Server) handleGuestAuth(c *gin.Context) { + acc, err := s.accounts.ProvisionGuest(c.Request.Context()) + if err != nil { + s.abortErr(c, err) + return + } + s.mintSession(c, acc) +} + +// emailRequest is an email-login code request. +type emailRequest struct { + Email string `json:"email"` +} + +// handleEmailRequest issues a login confirm-code to the email. It always reports +// success once the address is well-formed, so the response does not reveal +// whether an account already exists. +func (s *Server) handleEmailRequest(c *gin.Context) { + var req emailRequest + if err := c.ShouldBindJSON(&req); err != nil || req.Email == "" { + abortBadRequest(c, "email is required") + return + } + if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// emailLoginRequest verifies an email login code. +type emailLoginRequest struct { + Email string `json:"email"` + Code string `json:"code"` +} + +// handleEmailLogin verifies the code and mints a session for the owning account. +func (s *Server) handleEmailLogin(c *gin.Context) { + var req emailLoginRequest + if err := c.ShouldBindJSON(&req); err != nil || req.Email == "" || req.Code == "" { + abortBadRequest(c, "email and code are required") + return + } + acc, err := s.emails.LoginWithCode(c.Request.Context(), req.Email, req.Code) + if err != nil { + s.abortErr(c, err) + return + } + s.mintSession(c, acc) +} + +// tokenRequest carries an opaque session token. +type tokenRequest struct { + Token string `json:"token"` +} + +// handleResolveSession resolves a token to its account id. The gateway calls it +// on a session-cache miss. +func (s *Server) handleResolveSession(c *gin.Context) { + var req tokenRequest + if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" { + abortBadRequest(c, "token is required") + return + } + sess, err := s.sessions.Resolve(c.Request.Context(), req.Token) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, resolveResponse{UserID: sess.AccountID.String()}) +} + +// handleRevokeSession revokes the session for a token (idempotent). +func (s *Server) handleRevokeSession(c *gin.Context) { + var req tokenRequest + if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" { + abortBadRequest(c, "token is required") + return + } + if err := s.sessions.Revoke(c.Request.Context(), req.Token); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// mintSession creates a session for acc and writes the credential response. +func (s *Server) mintSession(c *gin.Context, acc account.Account) { + token, _, err := s.sessions.Create(c.Request.Context(), acc.ID) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, sessionResponseFor(token, acc)) +} diff --git a/backend/internal/server/handlers_test.go b/backend/internal/server/handlers_test.go new file mode 100644 index 0000000..c11afbe --- /dev/null +++ b/backend/internal/server/handlers_test.go @@ -0,0 +1,82 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/game" + "scrabble/backend/internal/session" +) + +// newRoutingServer builds a Server with non-nil (zero-value) services so the +// routes register. The tests below exercise only the request-validation and +// routing layers, which return before any service method is called; full +// endpoint behaviour against real services is covered by the integration suite. +func newRoutingServer() *Server { + return New(":0", Deps{ + Sessions: &session.Service{}, + Accounts: &account.Store{}, + Games: &game.Service{}, + }) +} + +func do(t *testing.T, s *Server, method, path, body string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + var rdr *strings.Reader + if body != "" { + rdr = strings.NewReader(body) + } else { + rdr = strings.NewReader("") + } + req := httptest.NewRequest(method, path, rdr) + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + return rec +} + +func TestAdminPingOK(t *testing.T) { + rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/admin/ping", "", nil) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"status":"ok"`) { + t.Fatalf("admin ping = %d %q", rec.Code, rec.Body.String()) + } +} + +func TestProfileRequiresUserID(t *testing.T) { + rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/user/profile", "", nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("profile without X-User-ID = %d, want 401", rec.Code) + } +} + +func TestResolveSessionRejectsEmptyToken(t *testing.T) { + rec := do(t, newRoutingServer(), http.MethodPost, "/api/v1/internal/sessions/resolve", `{}`, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("resolve with empty token = %d, want 400", rec.Code) + } +} + +func TestSubmitPlayRejectsBadDirection(t *testing.T) { + headers := map[string]string{"X-User-ID": uuid.New().String()} + path := "/api/v1/user/games/" + uuid.New().String() + "/play" + rec := do(t, newRoutingServer(), http.MethodPost, path, `{"dir":"X","tiles":[]}`, headers) + if rec.Code != http.StatusBadRequest { + t.Fatalf("submit play bad dir = %d, want 400", rec.Code) + } +} + +func TestSubmitPlayRejectsBadGameID(t *testing.T) { + headers := map[string]string{"X-User-ID": uuid.New().String()} + rec := do(t, newRoutingServer(), http.MethodPost, "/api/v1/user/games/not-a-uuid/play", `{"dir":"H"}`, headers) + if rec.Code != http.StatusBadRequest { + t.Fatalf("submit play bad game id = %d, want 400", rec.Code) + } +} diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go new file mode 100644 index 0000000..6f297d6 --- /dev/null +++ b/backend/internal/server/handlers_user.go @@ -0,0 +1,168 @@ +package server + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "scrabble/backend/internal/engine" +) + +// The /api/v1/user/* endpoints require X-User-ID (RequireUserID middleware). The +// backend treats that header as the sole identity input. + +// handleProfile returns the authenticated account's own profile. +func (s *Server) handleProfile(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + acc, err := s.accounts.GetByID(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, profileResponseFor(acc)) +} + +// submitPlayRequest places tiles in a direction on the player's turn. +type submitPlayRequest struct { + Dir string `json:"dir"` + Tiles []struct { + Row int `json:"row"` + Col int `json:"col"` + Letter string `json:"letter"` + Blank bool `json:"blank"` + } `json:"tiles"` +} + +// handleSubmitPlay validates, scores and commits a placement. +func (s *Server) handleSubmitPlay(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + gameID, ok := gameIDParam(c) + if !ok { + abortBadRequest(c, "invalid game id") + return + } + var req submitPlayRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + dir, ok := parseDirection(req.Dir) + if !ok { + abortBadRequest(c, "dir must be H or V") + return + } + tiles := make([]engine.TileRecord, 0, len(req.Tiles)) + for _, t := range req.Tiles { + tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}) + } + res, err := s.games.SubmitPlay(c.Request.Context(), gameID, uid, dir, tiles) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, moveResultDTOFrom(res)) +} + +// handleGameState returns the player's view of a game. +func (s *Server) handleGameState(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + gameID, ok := gameIDParam(c) + if !ok { + abortBadRequest(c, "invalid game id") + return + } + view, err := s.games.GameState(c.Request.Context(), gameID, uid) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, stateDTOFrom(view)) +} + +// enqueueRequest joins the per-variant auto-match pool. +type enqueueRequest struct { + Variant string `json:"variant"` +} + +// handleEnqueue joins the auto-match pool for a variant. +func (s *Server) handleEnqueue(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req enqueueRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + variant, err := engine.ParseVariant(req.Variant) + if err != nil { + abortBadRequest(c, "unknown variant") + return + } + res, err := s.matchmaker.Enqueue(c.Request.Context(), uid, variant) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, matchDTOFrom(res)) +} + +// handlePoll reports whether the caller has been paired since queueing. +func (s *Server) handlePoll(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + res, err := s.matchmaker.Poll(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, matchDTOFrom(res)) +} + +// chatPostRequest posts a per-game chat message. +type chatPostRequest struct { + Body string `json:"body"` +} + +// handleChatPost stores a chat message from the authenticated player. The sender +// IP is taken from the gateway-forwarded X-Forwarded-For header. +func (s *Server) handleChatPost(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + gameID, ok := gameIDParam(c) + if !ok { + abortBadRequest(c, "invalid game id") + return + } + var req chatPostRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + msg, err := s.social.PostMessage(c.Request.Context(), gameID, uid, req.Body, clientIP(c)) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, chatDTOFrom(msg)) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 1637d21..5598a79 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -18,7 +18,9 @@ import ( "go.uber.org/zap" "scrabble/backend/internal/account" + "scrabble/backend/internal/game" "scrabble/backend/internal/lobby" + "scrabble/backend/internal/session" "scrabble/backend/internal/social" "scrabble/backend/internal/telemetry" ) @@ -42,10 +44,13 @@ type Deps struct { // SessionsReady reports whether the session cache has been warmed. A nil // func skips the session-readiness check. SessionsReady func() bool - // Social, Matchmaker, Invitations and Emails are the Stage 4 domain services. - // They are held for the REST/stream handlers the gateway adds in Stage 6 (like - // the route groups, this is scaffolding exposed via accessors); the server - // itself does not route to them yet. + // Sessions, Accounts and Games are the identity, account and game-domain + // services the Stage 6 REST handlers route to. + Sessions *session.Service + Accounts *account.Store + Games *game.Service + // Social, Matchmaker, Invitations and Emails are the Stage 4 domain services + // the Stage 6 REST handlers route to. Social *social.Service Matchmaker *lobby.Matchmaker Invitations *lobby.InvitationService @@ -61,6 +66,9 @@ type Server struct { pingTimeout time.Duration sessionsReady func() bool + sessions *session.Service + accounts *account.Store + games *game.Service social *social.Service matchmaker *lobby.Matchmaker invitations *lobby.InvitationService @@ -94,6 +102,9 @@ func New(addr string, deps Deps) *Server { db: deps.DB, pingTimeout: pingTimeout, sessionsReady: deps.SessionsReady, + sessions: deps.Sessions, + accounts: deps.Accounts, + games: deps.Games, social: deps.Social, matchmaker: deps.Matchmaker, invitations: deps.Invitations, @@ -102,6 +113,7 @@ func New(addr string, deps Deps) *Server { } s.registerProbes(engine) s.registerAPIGroups(engine) + s.registerRoutes() return s } diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 3cf767f..6a45893 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -14,6 +14,7 @@ import ( "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" + "scrabble/backend/internal/notify" "scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/table" ) @@ -72,7 +73,12 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, if err := Clean(body); err != nil { return Message{}, err } - return svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP)) + msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP)) + if err != nil { + return Message{}, err + } + svc.emitChat(seats, senderID, msg) + return msg, nil } // Nudge records a nudge from senderID toward the player whose turn is awaited. The @@ -100,7 +106,27 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess if ok && svc.now().Sub(last) < nudgeInterval { return Message{}, ErrNudgeTooSoon } - return svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil) + msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil) + if err != nil { + return Message{}, err + } + if toMove >= 0 && toMove < len(seats) { + svc.pub.Publish(notify.Nudge(seats[toMove], gameID, senderID)) + } + return msg, nil +} + +// emitChat pushes a chat message to every seated player except the sender +// (best-effort live delivery; the recipients still read it via Messages). +func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) { + intents := make([]notify.Intent, 0, len(seats)) + for _, id := range seats { + if id == senderID { + continue + } + intents = append(intents, notify.ChatMessage(id, m.GameID, m.SenderID, m.ID.String(), m.Kind, m.Body, m.CreatedAt)) + } + svc.pub.Publish(intents...) } // LastNudgeAt returns the time of the most recent nudge senderID sent in the game diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index b8d648e..9c70006 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" "scrabble/backend/internal/account" + "scrabble/backend/internal/notify" ) // GameReader is the slice of the game domain the social package needs: the seated @@ -60,6 +61,7 @@ type Service struct { store *Store accounts *account.Store games GameReader + pub notify.Publisher now func() time.Time } @@ -70,6 +72,16 @@ func NewService(store *Store, accounts *account.Store, games GameReader) *Servic store: store, accounts: accounts, games: games, + pub: notify.Nop{}, now: func() time.Time { return time.Now().UTC() }, } } + +// SetNotifier installs the live-event publisher used to push chat messages and +// nudges to their recipients. It must be called during startup wiring, before +// the service serves traffic; the default is notify.Nop (no live events). +func (svc *Service) SetNotifier(p notify.Publisher) { + if p != nil { + svc.pub = p + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f30c0c3..7955482 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,12 +11,13 @@ not-yet-implemented components are marked *(planned)*. Three executables plus per-platform side-services: -- **`gateway`** *(planned)* — the only public ingress. Performs anti-abuse - (rate limiting), authenticates the player against the originating platform - (or an email/guest session), resolves the internal `user_id`, and forwards - authenticated traffic to `backend` with an `X-User-ID` header. Hosts an admin - surface behind HTTP Basic Auth. Bridges live events from `backend` to the - client. +- **`gateway`** — the only public ingress (module `scrabble/gateway`). Performs + anti-abuse (rate limiting), authenticates the player against the originating + platform (or an email/guest session), resolves the internal `user_id`, and + forwards authenticated traffic to `backend` with an `X-User-ID` header. Hosts an + admin surface behind HTTP Basic Auth. Bridges live events from `backend` to the + client. The shared wire contracts (the push proto and the FlatBuffers edge + payloads) live in `scrabble/pkg`, imported by both `gateway` and `backend`. - **`backend`** — internal-only service that owns every domain concern: identity/sessions, accounts and linking, lobby and matchmaking, the game runtime, the robot opponent, chat, notifications, statistics, history, and @@ -50,7 +51,17 @@ dropped). Horizontal scaling is explicit future work. - **client ↔ gateway**: **Connect-RPC + FlatBuffers** over HTTP/2 cleartext (`h2c`). Binary payloads, server-streaming for the in-app live channel, first-class JS clients (`@connectrpc/connect-web` + the `flatbuffers` npm - package). The contract is kept minimal. + package). The contract is kept minimal: a single `Gateway` service (defined in + `gateway/proto/edge/v1`) with `Execute(message_type, payload, request_id)` for + unary operations and `Subscribe` for the live stream. The proto envelope is a + thin carrier; the real request/response and event bodies are **FlatBuffers** + tables (`pkg/fbs`, the `scrabblefb` namespace) inside the `payload` bytes, which + the gateway transcodes to and from the backend's JSON. The session token rides + in the `Authorization: Bearer` header (there is no per-request signing, §3); + auth operations are unauthenticated and return the minted token. A unary + operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP + 200); only edge failures (rate limit, missing session, unknown type, internal) + surface as Connect error codes. - **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects `X-User-ID` for authenticated requests; `backend` never re-derives identity from the body. @@ -76,9 +87,13 @@ arrive from a platform rather than completing a mandatory registration). token (never the plaintext), keeps a warmed in-memory cache for fast resolution, and treats sessions as **revoke-only** — they have no TTL and live until explicitly revoked (`status` → `revoked`). -- **Guest** = ephemeral web session (no platform, no email): session-only, - nothing persisted; restricted to auto-match, with no friends and no - stats/history. Platform users are auto-provisioned **durable** accounts. +- **Guest** = ephemeral web session (no platform, no email). A guest is backed by + a durable `accounts` row flagged `is_guest` and carrying **no identity** — the + row is a technical necessity (the `sessions` and `game_players` foreign keys + require one, the same way the robot pool is durable), not a profile: no + friends, statistics or history are kept for it, and it is restricted to + auto-match. Platform and email users are auto-provisioned **durable** accounts + with an identity. (Reaping abandoned guest rows is deferred — PLAN.md TODO-3.) ## 4. Accounts, identities, linking & merge @@ -231,9 +246,9 @@ requires (there is no DM surface; chat is per-game). auto-match with the seat order randomised for first-move fairness. The pool is lost on restart (players re-queue) and is anonymous, so it does not consult blocks. After **10 s** with no human a background reaper substitutes a pooled - robot (§7) and starts the game. A queued player learns of a pairing or a - substitution through the matchmaker's `Poll`, the interim delivery seam until the - live match-found notification (§10). + robot (§7) and starts the game. On a pairing or substitution the matchmaker + emits a **match-found** notification (§10), delivered over the live stream; + `Poll` remains as a fallback for a client that is not currently streaming. - **Friends**: a **request → accept** graph (one `friendships` table) — add by friend list or internal ID now, by platform deep-link with Stage 8. Declining or cancelling removes the pending request; blocking someone severs an existing @@ -271,7 +286,8 @@ requires (there is no DM surface; chat is per-game). Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary keys are application-generated **UUIDv7**. - Tables: `accounts` (durable internal accounts; Stage 3 added the away-window - columns `away_start`/`away_end` and the hint wallet `hint_balance`), + columns `away_start`/`away_end` and the hint wallet `hint_balance`; Stage 6's + migration `00005` added the `is_guest` flag for ephemeral guest rows), `identities` (platform/email/robot identities, unique `(kind, external_id)`; Stage 5's migration `00004` admits the `robot` kind), `sessions` (revoke-only opaque-token hashes), the Stage 3 game tables @@ -290,8 +306,9 @@ requires (there is no DM surface; chat is per-game). Each game is serialised by a per-game lock; a persistence failure evicts the live game so the next access rebuilds from the journal. `game_players` records each seat's account, running score, hints used and winner flag. -- **Statistics** (`account_stats`, recomputed on each finish, durable accounts - only — guests never appear): wins, losses, **draws**, max points in a game, and +- **Statistics** (`account_stats`, recomputed on each finish for durable + non-guest accounts only — the finish-time recompute skips any `is_guest` + seat): wins, losses, **draws**, max points in a game, and max points for a single **move** (which already folds in every word the move formed plus the all-tiles bonus). A tie increments draws only; a resignation or timeout is a loss for the acting player. @@ -319,15 +336,21 @@ does not cover. ## 10. Notifications -Two channels: **platform-native push** (out-of-app, via the platform -side-service — your-turn, nudge) and the **in-app live stream** (chat, -opponent-moved, while the app is open). Backend emits notification intents; -delivery fans out to the appropriate channel. A **match-found** event (a human -pairing or a robot substitution in auto-match, §8) belongs to the same fabric. -Stage 4 **persists** the notification-worthy events (chat messages and nudges) but -does not yet deliver them, and Stage 5's match-found has no live channel yet: the -gRPC stream to the gateway and the platform push arrive in Stage 6 / 8. Until then -a waiting client retrieves its started game by polling the matchmaker (`Poll`). +Two channels: the **in-app live stream** (delivered from Stage 6) and +**platform-native push** (out-of-app, via the platform side-service — Stage 8). +The backend emits notification intents through an in-process hub +(`internal/notify`, a `Publisher` seam installed on the game, social and lobby +services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`, +`pkg/proto/push/v1`) carries every event, and the gateway fans them out by +`user_id` to each client's Connect `Subscribe` stream while the app is open. The +catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so +robot-driver and timeout-sweeper moves emit too), **chat-message** and **nudge** +(from the social service), and **match-found** (from the matchmaker, §8). Event +payloads are FlatBuffers-encoded by the backend and forwarded verbatim. A client +that is not currently streaming falls back to the matchmaker's `Poll` for +match-found. Out-of-app platform push (your-turn, nudge) is wired in Stage 8; +session-revocation events and cursor-based stream resume are deferred +(single-instance MVP). ## 11. Observability @@ -342,6 +365,9 @@ a waiting client retrieves its started game by polling the matchmaker (`Poll`). client-measured RTT piggybacked on the next request is a later enhancement. - Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the database answers a bounded ping and the session cache is warmed). +- The backend serves a **second listener** — a gRPC server + (`BACKEND_GRPC_ADDR`, default `:9090`) for the live-event push stream to the + gateway — alongside the HTTP listener; both start together and stop on signal. ## 12. Security boundaries @@ -351,7 +377,7 @@ a waiting client retrieves its started game by polling the matchmaker (`Poll`). | Platform credential validation, session minting | gateway | | Session → `user_id` resolution, `X-User-ID` injection | gateway | | Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) | -| Admin authentication | gateway Basic Auth → backend admin endpoints | +| Admin authentication | gateway validates HTTP Basic Auth (`GATEWAY_ADMIN_*`), then reverse-proxies to backend admin endpoints | | backend ↔ gateway trust | the network (only gateway may reach backend) | This is an explicit, accepted MVP risk: compromise of the gateway↔backend diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 08fae4a..0fc268f 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -14,7 +14,10 @@ A player arrives from a platform (Telegram first), via email login, or as an ephemeral guest. The gateway validates the credential once and mints a thin session token; the backend resolves it to an internal `user_id`. Guests are session-only with restricted features (auto-match only; no friends, stats or -history). +history). While the app is open the client keeps a live stream and receives +in-app updates in real time — the opponent's move, your turn, chat, nudges and a +found match; out-of-app push (your turn, nudge) is delivered by the platform +later (Stage 8). ### Accounts, linking & merge *(Stage 1 / 10)* First platform contact auto-provisions a durable account. From the profile a diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 7d4ef71..b29ea58 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -13,7 +13,10 @@ эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий session-токен; backend сопоставляет его с внутренним `user_id`. Гость — только сессия, с урезанными функциями (только авто-подбор; без друзей, -статистики и истории). +статистики и истории). Пока приложение открыто, клиент держит живой стрим и +получает обновления в реальном времени — ход соперника, ваш ход, чат, nudge и +найденный матч; внеприложенческий push (ваш ход, nudge) платформа доставит +позже (Stage 8). ### Аккаунты, привязка и слияние *(Stage 1 / 10)* Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок diff --git a/docs/TESTING.md b/docs/TESTING.md index 44bded5..cd6d2f9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -7,7 +7,8 @@ tests or touching CI. - **Go unit tests** — table-driven where it helps; `testing` + standard library. Every functional change ships with regression coverage. Run: - `go test -count=1 ./backend/...` (the module list grows with the workspace). + `go test -count=1 ./backend/... ./pkg/... ./gateway/...` (the module list grows + with the workspace). - **Integration** *(Stage 1+)* — Postgres-backed tests behind the `integration` build tag spin a throwaway `postgres:17-alpine` via `testcontainers-go`. They live in `backend/internal/inttest` and run with @@ -56,6 +57,20 @@ tests or touching CI. drives a robot through a full auto-match to a natural end (asserting a robot statistics row), the matchmaker substitution end-to-end (enqueue → reap → `[human, robot]`, discoverable via `Poll`), and a proactive 12-hour nudge. +- **Gateway & contracts** *(Stage 6+)* — `backend/internal/notify` unit-tests the + hub fan-out (delivery, overflow drop, unsubscribe) and the FlatBuffers event + constructors (payload round-trip). `gateway/...` unit-tests are hermetic (no + real network — an `httptest` fake backend and fixtures): the Telegram initData + HMAC validator (genuine, tampered, wrong-token, stale), the session cache + (hit/miss/fallback, TTL re-resolve, invalidate), the rate limiter (burst, + per-key isolation, per-window), the push hub (per-user routing, overflow, + unsubscribe), the transcode round-trips (FlatBuffers↔JSON, X-User-ID + forwarding, nested GameView, domain-code surfacing), the admin Basic-Auth + reverse proxy (401 / forward), and a full Connect `Execute` path end to end + (guest auth, unauthenticated rejection, unknown message type). The backend gains + the **guest** lifecycle (a guest plays an auto-match to a natural end yet accrues + no statistics) and the **email-as-login** flow (request/verify, returning user) + in `inttest`. ## Principles diff --git a/gateway/Makefile b/gateway/Makefile new file mode 100644 index 0000000..558825a --- /dev/null +++ b/gateway/Makefile @@ -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 diff --git a/gateway/README.md b/gateway/README.md new file mode 100644 index 0000000..a96d6fd --- /dev/null +++ b/gateway/README.md @@ -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. diff --git a/gateway/buf.gen.yaml b/gateway/buf.gen.yaml new file mode 100644 index 0000000..5dfd52d --- /dev/null +++ b/gateway/buf.gen.yaml @@ -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 diff --git a/gateway/buf.yaml b/gateway/buf.yaml new file mode 100644 index 0000000..c7e30e3 --- /dev/null +++ b/gateway/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go new file mode 100644 index 0000000..a13a61e --- /dev/null +++ b/gateway/cmd/gateway/main.go @@ -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() +} diff --git a/gateway/go.mod b/gateway/go.mod new file mode 100644 index 0000000..0117328 --- /dev/null +++ b/gateway/go.mod @@ -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 +) diff --git a/gateway/go.sum b/gateway/go.sum new file mode 100644 index 0000000..dd2b2f6 --- /dev/null +++ b/gateway/go.sum @@ -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= diff --git a/gateway/internal/admin/admin.go b/gateway/internal/admin/admin.go new file mode 100644 index 0000000..e006c21 --- /dev/null +++ b/gateway/internal/admin/admin.go @@ -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/ path to /api/v1/admin/. +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 +} diff --git a/gateway/internal/admin/admin_test.go b/gateway/internal/admin/admin_test.go new file mode 100644 index 0000000..f2193b7 --- /dev/null +++ b/gateway/internal/admin/admin_test.go @@ -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) + } +} diff --git a/gateway/internal/auth/telegram.go b/gateway/internal/auth/telegram.go new file mode 100644 index 0000000..c4352b9 --- /dev/null +++ b/gateway/internal/auth/telegram.go @@ -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) +} diff --git a/gateway/internal/auth/telegram_test.go b/gateway/internal/auth/telegram_test.go new file mode 100644 index 0000000..1280bd3 --- /dev/null +++ b/gateway/internal/auth/telegram_test.go @@ -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") + } +} diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go new file mode 100644 index 0000000..7554296 --- /dev/null +++ b/gateway/internal/backendclient/api.go @@ -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 +} diff --git a/gateway/internal/backendclient/client.go b/gateway/internal/backendclient/client.go new file mode 100644 index 0000000..9252e09 --- /dev/null +++ b/gateway/internal/backendclient/client.go @@ -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}) +} diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go new file mode 100644 index 0000000..3906e65 --- /dev/null +++ b/gateway/internal/config/config.go @@ -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 +} diff --git a/gateway/internal/connectsrv/errors.go b/gateway/internal/connectsrv/errors.go new file mode 100644 index 0000000..68a14c6 --- /dev/null +++ b/gateway/internal/connectsrv/errors.go @@ -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) +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go new file mode 100644 index 0000000..a8acae2 --- /dev/null +++ b/gateway/internal/connectsrv/server.go @@ -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 " 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 +} diff --git a/gateway/internal/connectsrv/server_test.go b/gateway/internal/connectsrv/server_test.go new file mode 100644 index 0000000..59fcadf --- /dev/null +++ b/gateway/internal/connectsrv/server_test.go @@ -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)) + } +} diff --git a/gateway/internal/push/hub.go b/gateway/internal/push/hub.go new file mode 100644 index 0000000..656a796 --- /dev/null +++ b/gateway/internal/push/hub.go @@ -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) +} diff --git a/gateway/internal/push/hub_test.go b/gateway/internal/push/hub_test.go new file mode 100644 index 0000000..949c987 --- /dev/null +++ b/gateway/internal/push/hub_test.go @@ -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 +} diff --git a/gateway/internal/ratelimit/ratelimit.go b/gateway/internal/ratelimit/ratelimit.go new file mode 100644 index 0000000..e41f2af --- /dev/null +++ b/gateway/internal/ratelimit/ratelimit.go @@ -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) + } + } +} diff --git a/gateway/internal/ratelimit/ratelimit_test.go b/gateway/internal/ratelimit/ratelimit_test.go new file mode 100644 index 0000000..49f50e2 --- /dev/null +++ b/gateway/internal/ratelimit/ratelimit_test.go @@ -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) + } +} diff --git a/gateway/internal/session/cache.go b/gateway/internal/session/cache.go new file mode 100644 index 0000000..6e254a5 --- /dev/null +++ b/gateway/internal/session/cache.go @@ -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) + } +} diff --git a/gateway/internal/session/cache_test.go b/gateway/internal/session/cache_test.go new file mode 100644 index 0000000..173e601 --- /dev/null +++ b/gateway/internal/session/cache_test.go @@ -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) + } +} diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go new file mode 100644 index 0000000..08b529c --- /dev/null +++ b/gateway/internal/transcode/encode.go @@ -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)) +} diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go new file mode 100644 index 0000000..e6910cd --- /dev/null +++ b/gateway/internal/transcode/transcode.go @@ -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 +} diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go new file mode 100644 index 0000000..cb644b7 --- /dev/null +++ b/gateway/internal/transcode/transcode_test.go @@ -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) + } +} diff --git a/gateway/proto/edge/v1/edge.pb.go b/gateway/proto/edge/v1/edge.pb.go new file mode 100644 index 0000000..95c7ba2 --- /dev/null +++ b/gateway/proto/edge/v1/edge.pb.go @@ -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 +} diff --git a/gateway/proto/edge/v1/edge.proto b/gateway/proto/edge/v1/edge.proto new file mode 100644 index 0000000..447c63f --- /dev/null +++ b/gateway/proto/edge/v1/edge.proto @@ -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; +} diff --git a/gateway/proto/edge/v1/edgev1connect/edge.connect.go b/gateway/proto/edge/v1/edgev1connect/edge.connect.go new file mode 100644 index 0000000..dc831fb --- /dev/null +++ b/gateway/proto/edge/v1/edgev1connect/edge.connect.go @@ -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")) +} diff --git a/go.work b/go.work index 73ff722..d79d833 100644 --- a/go.work +++ b/go.work @@ -2,8 +2,19 @@ go 1.26.3 use ./backend +use ( + ./gateway + ./pkg +) + // The scrabble-solver engine is consumed in-process as a library. Its module // path is the bare "scrabble-solver" (not a URL), so it cannot be fetched as a // versioned dependency via VCS; the workspace points it at the sibling checkout. // CI clones that sibling next to this repository before building. replace scrabble-solver => ../scrabble-solver + +// scrabble/pkg holds the shared wire contracts (push proto + FlatBuffers edge +// payloads) imported by both backend and gateway. Its module path has no dot, so +// like scrabble-solver it cannot be fetched as a versioned dependency; the +// replace points the v0.0.0 require at the in-repo module directory. +replace scrabble/pkg v0.0.0 => ./pkg diff --git a/go.work.sum b/go.work.sum index 4adfc57..af41602 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,21 +1,33 @@ +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo= +connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= github.com/ClickHouse/clickhouse-go/v2 v2.45.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/iliadenisov/alphabet v1.1.0 h1:d87N7Rmpjj9FgL7bvEaqLdaIaNch2hC6HvkbKGhn7Hk= @@ -38,12 +50,14 @@ github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHu github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU= github.com/vertica/vertica-sql-go v1.3.6/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= github.com/volatiletech/inflect v0.0.1/go.mod h1:IBti31tG6phkHitLlr5j7shC5SOo//x0AjDzaJU1PLA= @@ -57,18 +71,22 @@ github.com/ydb-platform/ydb-go-genproto v0.0.0-20260311095541-ebbf792c1180/go.mo github.com/ydb-platform/ydb-go-sdk/v3 v3.135.0/go.mod h1:VYUUkRJkKuQPkIpgtZJj6+58Fa2g8ccAqdmaaK6HP5k= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= diff --git a/pkg/Makefile b/pkg/Makefile new file mode 100644 index 0000000..62f804f --- /dev/null +++ b/pkg/Makefile @@ -0,0 +1,40 @@ +# Code generation for the shared wire contracts (proto push channel + the +# FlatBuffers edge payloads). The generated Go is COMMITTED; CI only builds it, +# so this Makefile is a dev-time tool (the same model as backend/cmd/jetgen). +# +# Prerequisites: +# make tools # go install the local protoc-gen-* plugins +# flatc $(REQUIRED_FLATC) # for `make fbs` +# Then: +# make gen # proto + fbs +.PHONY: gen proto fbs flatc-check tools + +# Pinned flatc version: the committed Go bindings under fbs/scrabblefb/ and the +# flatbuffers Go runtime (go.mod) are on this version. Generating with a +# different flatc silently churns output and can flip wire defaults. +REQUIRED_FLATC := 23.5.26 + +GOBIN := $(shell go env GOBIN) +ifeq ($(GOBIN),) +GOBIN := $(shell go env GOPATH)/bin +endif + +gen: proto fbs + +# tools installs the local buf plugins. Pin versions to the runtime libraries in +# go.mod (google.golang.org/protobuf, google.golang.org/grpc). +tools: + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 + +proto: + PATH="$(GOBIN):$$PATH" buf generate + +flatc-check: + @command -v flatc >/dev/null || { echo "flatc not found; install flatc $(REQUIRED_FLATC)"; exit 1; } + @flatc --version | grep -q "$(REQUIRED_FLATC)" || { echo "flatc $(REQUIRED_FLATC) required; found '$$(flatc --version)'"; exit 1; } + +# --go-module-name rewrites cross-table imports to the fully-qualified module +# path so the generated code links without local replace directives. +fbs: flatc-check + flatc --go --go-module-name scrabble/pkg/fbs -o fbs fbs/scrabble.fbs diff --git a/pkg/README.md b/pkg/README.md new file mode 100644 index 0000000..9d0cb7d --- /dev/null +++ b/pkg/README.md @@ -0,0 +1,40 @@ +# pkg + +Shared wire contracts for the Scrabble platform (module `scrabble/pkg`), +imported by both `backend` and `gateway`. It carries no logic — only the +generated message types and the schemas they come from. + +## Layout + +``` +proto/push/v1/ # backend -> gateway live-event gRPC channel (Push.Subscribe) + # committed generated Go (*.pb.go, *_grpc.pb.go) +fbs/scrabble.fbs # FlatBuffers edge payloads (one `scrabblefb` namespace) +fbs/scrabblefb/ # committed generated Go for the schema +``` + +- **`proto/push/v1`** is the single gRPC server-stream the backend exposes and + the gateway subscribes to (`Event{user_id, kind, payload, event_id}`); the + `payload` is an opaque FlatBuffers body the gateway forwards verbatim. +- **`fbs`** holds the client↔gateway request/response and event payloads as + FlatBuffers tables. The backend encodes the push payloads from these types; the + gateway transcodes the rest to and from the backend's JSON; the UI generates + TypeScript from the same `.fbs` (Stage 7). + +## Generated code + +Committed (CI only builds it); regenerate dev-time after editing the schemas: + +```sh +make -C pkg tools # go install protoc-gen-go + protoc-gen-go-grpc +make -C pkg gen # buf generate (proto) + flatc (fbs) +``` + +`flatc` is pinned to **23.5.26** to match the `github.com/google/flatbuffers` +Go runtime in `go.mod`; generating with another version is refused. + +## Workspace wiring + +`scrabble/pkg` is a bare-path module (no dot), so — like `scrabble-solver` — it +cannot be fetched as a versioned dependency. `go.work` carries `use ./pkg` and +`replace scrabble/pkg v0.0.0 => ./pkg`; consumers `require scrabble/pkg v0.0.0`. diff --git a/pkg/buf.gen.yaml b/pkg/buf.gen.yaml new file mode 100644 index 0000000..f05584f --- /dev/null +++ b/pkg/buf.gen.yaml @@ -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-go-grpc + out: proto + opt: + - paths=source_relative diff --git a/pkg/buf.yaml b/pkg/buf.yaml new file mode 100644 index 0000000..c7e30e3 --- /dev/null +++ b/pkg/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs new file mode 100644 index 0000000..f333587 --- /dev/null +++ b/pkg/fbs/scrabble.fbs @@ -0,0 +1,205 @@ +// FlatBuffers payloads for the client <-> gateway edge transport (Stage 6). +// +// Every request and response that rides inside the Connect envelope +// (gateway/proto/edge) `payload` field, and every push Event payload, is one of +// these tables. They are the binary wire contract shared with the UI, which +// generates TypeScript from this same schema (Stage 7). A single namespace keeps +// nested tables (GameView inside MoveResult / MatchResult) free of +// cross-namespace imports. Keep this schema and the backend JSON DTOs in lockstep +// — the gateway transcodes one to the other. +// +// Generate Go with `make fbs` (flatc, version-pinned). The committed output lives +// in fbs/scrabblefb/. +namespace scrabblefb; + +// --- shared building blocks --- + +// TileRecord is one placed (or to-place) tile: its board coordinate, the concrete +// letter ("?" when read from a hand for a blank) and whether it came from a blank. +table TileRecord { + row:int; + col:int; + letter:string; + blank:bool; +} + +// SeatView is one seat's public standing in a game. +table SeatView { + seat:int; + account_id:string; + score:int; + hints_used:int; + is_winner:bool; +} + +// GameView is the shared (non-private) game summary. +table GameView { + id:string; + variant:string; + dict_version:string; + status:string; + players:int; + to_move:int; + turn_timeout_secs:int; + move_count:int; + end_reason:string; + seats:[SeatView]; +} + +// MoveRecord is one decoded move (a committed play, or a hint preview). +table MoveRecord { + player:int; + action:string; + dir:string; + main_row:int; + main_col:int; + tiles:[TileRecord]; + words:[string]; + count:int; + score:int; + total:int; +} + +// --- auth (unauthenticated) --- + +// TelegramLoginRequest carries the platform launch data; the gateway validates +// its HMAC before forwarding the extracted identity to the backend. +table TelegramLoginRequest { + init_data:string; +} + +// GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional +// preferred-language hint. +table GuestLoginRequest { + locale:string; +} + +// EmailRequestRequest asks the backend to send a login confirm-code to email. +table EmailRequestRequest { + email:string; +} + +// EmailLoginRequest logs in (or provisions) the account owning email, verifying +// the confirm-code. +table EmailLoginRequest { + email:string; + code:string; +} + +// Session is the minted credential returned by every auth operation. +table Session { + token:string; + user_id:string; + is_guest:bool; + display_name:string; +} + +// Ack is a simple success acknowledgement (e.g. an email-code request). +table Ack { + ok:bool; +} + +// --- profile (authenticated) --- + +// Profile is the authenticated account's own profile view. +table Profile { + user_id:string; + display_name:string; + preferred_language:string; + time_zone:string; + hint_balance:int; + block_chat:bool; + block_friend_requests:bool; + is_guest:bool; +} + +// --- game (authenticated) --- + +// SubmitPlayRequest places tiles in a direction on the player's turn. +table SubmitPlayRequest { + game_id:string; + dir:string; + tiles:[TileRecord]; +} + +// MoveResult is the outcome of a committed move: the move and the post-move game. +table MoveResult { + move:MoveRecord; + game:GameView; +} + +// StateRequest asks for the requesting player's view of a game. +table StateRequest { + game_id:string; +} + +// StateView is a player's view of a game: the shared summary plus their private +// rack, the bag size and their remaining hint budget. +table StateView { + game:GameView; + seat:int; + rack:[string]; + bag_len:int; + hints_remaining:int; +} + +// --- lobby (authenticated) --- + +// EnqueueRequest joins the per-variant auto-match pool. +table EnqueueRequest { + variant:string; +} + +// MatchResult reports whether the caller has been paired into a game yet. +table MatchResult { + matched:bool; + game:GameView; +} + +// --- chat (authenticated) --- + +// ChatPostRequest posts a per-game chat message. +table ChatPostRequest { + game_id:string; + body:string; +} + +// ChatMessage is one stored chat message or nudge. +table ChatMessage { + id:string; + game_id:string; + sender_id:string; + kind:string; + body:string; + created_at_unix:long; +} + +// --- push event payloads --- + +// YourTurnEvent signals that it is now the recipient's turn. +table YourTurnEvent { + game_id:string; + deadline_unix:long; +} + +// OpponentMovedEvent summarises a move another seat just committed; the client +// refetches the full state. +table OpponentMovedEvent { + game_id:string; + seat:int; + action:string; + score:int; + total:int; +} + +// NudgeEvent signals that a player nudged the recipient. +table NudgeEvent { + game_id:string; + from_user_id:string; +} + +// MatchFoundEvent signals that an auto-match pairing (or robot substitution) +// started a game the recipient is seated in. +table MatchFoundEvent { + game_id:string; +} diff --git a/pkg/fbs/scrabblefb/Ack.go b/pkg/fbs/scrabblefb/Ack.go new file mode 100644 index 0000000..7a54f8f --- /dev/null +++ b/pkg/fbs/scrabblefb/Ack.go @@ -0,0 +1,64 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type Ack struct { + _tab flatbuffers.Table +} + +func GetRootAsAck(buf []byte, offset flatbuffers.UOffsetT) *Ack { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &Ack{} + x.Init(buf, n+offset) + return x +} + +func FinishAckBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsAck(buf []byte, offset flatbuffers.UOffsetT) *Ack { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &Ack{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedAckBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *Ack) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *Ack) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *Ack) Ok() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *Ack) MutateOk(n bool) bool { + return rcv._tab.MutateBoolSlot(4, n) +} + +func AckStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func AckAddOk(builder *flatbuffers.Builder, ok bool) { + builder.PrependBoolSlot(0, ok, false) +} +func AckEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/ChatMessage.go b/pkg/fbs/scrabblefb/ChatMessage.go new file mode 100644 index 0000000..64eba22 --- /dev/null +++ b/pkg/fbs/scrabblefb/ChatMessage.go @@ -0,0 +1,119 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type ChatMessage struct { + _tab flatbuffers.Table +} + +func GetRootAsChatMessage(buf []byte, offset flatbuffers.UOffsetT) *ChatMessage { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &ChatMessage{} + x.Init(buf, n+offset) + return x +} + +func FinishChatMessageBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsChatMessage(buf []byte, offset flatbuffers.UOffsetT) *ChatMessage { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &ChatMessage{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedChatMessageBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *ChatMessage) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *ChatMessage) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *ChatMessage) Id() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ChatMessage) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ChatMessage) SenderId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ChatMessage) Kind() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ChatMessage) Body() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ChatMessage) CreatedAtUnix() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *ChatMessage) MutateCreatedAtUnix(n int64) bool { + return rcv._tab.MutateInt64Slot(14, n) +} + +func ChatMessageStart(builder *flatbuffers.Builder) { + builder.StartObject(6) +} +func ChatMessageAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) +} +func ChatMessageAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(gameId), 0) +} +func ChatMessageAddSenderId(builder *flatbuffers.Builder, senderId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(senderId), 0) +} +func ChatMessageAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(kind), 0) +} +func ChatMessageAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(body), 0) +} +func ChatMessageAddCreatedAtUnix(builder *flatbuffers.Builder, createdAtUnix int64) { + builder.PrependInt64Slot(5, createdAtUnix, 0) +} +func ChatMessageEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/ChatPostRequest.go b/pkg/fbs/scrabblefb/ChatPostRequest.go new file mode 100644 index 0000000..47c861e --- /dev/null +++ b/pkg/fbs/scrabblefb/ChatPostRequest.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type ChatPostRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsChatPostRequest(buf []byte, offset flatbuffers.UOffsetT) *ChatPostRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &ChatPostRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishChatPostRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsChatPostRequest(buf []byte, offset flatbuffers.UOffsetT) *ChatPostRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &ChatPostRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedChatPostRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *ChatPostRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *ChatPostRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *ChatPostRequest) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *ChatPostRequest) Body() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func ChatPostRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func ChatPostRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func ChatPostRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(body), 0) +} +func ChatPostRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/EmailLoginRequest.go b/pkg/fbs/scrabblefb/EmailLoginRequest.go new file mode 100644 index 0000000..b98b8e5 --- /dev/null +++ b/pkg/fbs/scrabblefb/EmailLoginRequest.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type EmailLoginRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsEmailLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailLoginRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &EmailLoginRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishEmailLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsEmailLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailLoginRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &EmailLoginRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedEmailLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *EmailLoginRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *EmailLoginRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *EmailLoginRequest) Email() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *EmailLoginRequest) Code() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func EmailLoginRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func EmailLoginRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0) +} +func EmailLoginRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(code), 0) +} +func EmailLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/EmailRequestRequest.go b/pkg/fbs/scrabblefb/EmailRequestRequest.go new file mode 100644 index 0000000..3173481 --- /dev/null +++ b/pkg/fbs/scrabblefb/EmailRequestRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type EmailRequestRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsEmailRequestRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailRequestRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &EmailRequestRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishEmailRequestRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsEmailRequestRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailRequestRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &EmailRequestRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedEmailRequestRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *EmailRequestRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *EmailRequestRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *EmailRequestRequest) Email() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func EmailRequestRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0) +} +func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/EnqueueRequest.go b/pkg/fbs/scrabblefb/EnqueueRequest.go new file mode 100644 index 0000000..645ff5a --- /dev/null +++ b/pkg/fbs/scrabblefb/EnqueueRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type EnqueueRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsEnqueueRequest(buf []byte, offset flatbuffers.UOffsetT) *EnqueueRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &EnqueueRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishEnqueueRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsEnqueueRequest(buf []byte, offset flatbuffers.UOffsetT) *EnqueueRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &EnqueueRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedEnqueueRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *EnqueueRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *EnqueueRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *EnqueueRequest) Variant() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func EnqueueRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func EnqueueRequestAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0) +} +func EnqueueRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/GameView.go b/pkg/fbs/scrabblefb/GameView.go new file mode 100644 index 0000000..1dcd1a4 --- /dev/null +++ b/pkg/fbs/scrabblefb/GameView.go @@ -0,0 +1,190 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type GameView struct { + _tab flatbuffers.Table +} + +func GetRootAsGameView(buf []byte, offset flatbuffers.UOffsetT) *GameView { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &GameView{} + x.Init(buf, n+offset) + return x +} + +func FinishGameViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsGameView(buf []byte, offset flatbuffers.UOffsetT) *GameView { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &GameView{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedGameViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *GameView) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *GameView) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *GameView) Id() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *GameView) Variant() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *GameView) DictVersion() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *GameView) Status() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *GameView) Players() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *GameView) MutatePlayers(n int32) bool { + return rcv._tab.MutateInt32Slot(12, n) +} + +func (rcv *GameView) ToMove() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *GameView) MutateToMove(n int32) bool { + return rcv._tab.MutateInt32Slot(14, n) +} + +func (rcv *GameView) TurnTimeoutSecs() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *GameView) MutateTurnTimeoutSecs(n int32) bool { + return rcv._tab.MutateInt32Slot(16, n) +} + +func (rcv *GameView) MoveCount() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *GameView) MutateMoveCount(n int32) bool { + return rcv._tab.MutateInt32Slot(18, n) +} + +func (rcv *GameView) EndReason() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *GameView) Seats(obj *SeatView, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *GameView) SeatsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func GameViewStart(builder *flatbuffers.Builder) { + builder.StartObject(10) +} +func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) +} +func GameViewAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(variant), 0) +} +func GameViewAddDictVersion(builder *flatbuffers.Builder, dictVersion flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(dictVersion), 0) +} +func GameViewAddStatus(builder *flatbuffers.Builder, status flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(status), 0) +} +func GameViewAddPlayers(builder *flatbuffers.Builder, players int32) { + builder.PrependInt32Slot(4, players, 0) +} +func GameViewAddToMove(builder *flatbuffers.Builder, toMove int32) { + builder.PrependInt32Slot(5, toMove, 0) +} +func GameViewAddTurnTimeoutSecs(builder *flatbuffers.Builder, turnTimeoutSecs int32) { + builder.PrependInt32Slot(6, turnTimeoutSecs, 0) +} +func GameViewAddMoveCount(builder *flatbuffers.Builder, moveCount int32) { + builder.PrependInt32Slot(7, moveCount, 0) +} +func GameViewAddEndReason(builder *flatbuffers.Builder, endReason flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(endReason), 0) +} +func GameViewAddSeats(builder *flatbuffers.Builder, seats flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(seats), 0) +} +func GameViewStartSeatsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/GuestLoginRequest.go b/pkg/fbs/scrabblefb/GuestLoginRequest.go new file mode 100644 index 0000000..2d06f6e --- /dev/null +++ b/pkg/fbs/scrabblefb/GuestLoginRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type GuestLoginRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsGuestLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *GuestLoginRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &GuestLoginRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishGuestLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsGuestLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *GuestLoginRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &GuestLoginRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedGuestLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *GuestLoginRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *GuestLoginRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *GuestLoginRequest) Locale() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func GuestLoginRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(locale), 0) +} +func GuestLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/MatchFoundEvent.go b/pkg/fbs/scrabblefb/MatchFoundEvent.go new file mode 100644 index 0000000..fc7c0f8 --- /dev/null +++ b/pkg/fbs/scrabblefb/MatchFoundEvent.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type MatchFoundEvent struct { + _tab flatbuffers.Table +} + +func GetRootAsMatchFoundEvent(buf []byte, offset flatbuffers.UOffsetT) *MatchFoundEvent { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &MatchFoundEvent{} + x.Init(buf, n+offset) + return x +} + +func FinishMatchFoundEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsMatchFoundEvent(buf []byte, offset flatbuffers.UOffsetT) *MatchFoundEvent { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &MatchFoundEvent{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedMatchFoundEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *MatchFoundEvent) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *MatchFoundEvent) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *MatchFoundEvent) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func MatchFoundEventStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func MatchFoundEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func MatchFoundEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/MatchResult.go b/pkg/fbs/scrabblefb/MatchResult.go new file mode 100644 index 0000000..7526cdc --- /dev/null +++ b/pkg/fbs/scrabblefb/MatchResult.go @@ -0,0 +1,80 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type MatchResult struct { + _tab flatbuffers.Table +} + +func GetRootAsMatchResult(buf []byte, offset flatbuffers.UOffsetT) *MatchResult { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &MatchResult{} + x.Init(buf, n+offset) + return x +} + +func FinishMatchResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsMatchResult(buf []byte, offset flatbuffers.UOffsetT) *MatchResult { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &MatchResult{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedMatchResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *MatchResult) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *MatchResult) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *MatchResult) Matched() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *MatchResult) MutateMatched(n bool) bool { + return rcv._tab.MutateBoolSlot(4, n) +} + +func (rcv *MatchResult) Game(obj *GameView) *GameView { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(GameView) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func MatchResultStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func MatchResultAddMatched(builder *flatbuffers.Builder, matched bool) { + builder.PrependBoolSlot(0, matched, false) +} +func MatchResultAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(game), 0) +} +func MatchResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/MoveRecord.go b/pkg/fbs/scrabblefb/MoveRecord.go new file mode 100644 index 0000000..2dc76d2 --- /dev/null +++ b/pkg/fbs/scrabblefb/MoveRecord.go @@ -0,0 +1,210 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type MoveRecord struct { + _tab flatbuffers.Table +} + +func GetRootAsMoveRecord(buf []byte, offset flatbuffers.UOffsetT) *MoveRecord { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &MoveRecord{} + x.Init(buf, n+offset) + return x +} + +func FinishMoveRecordBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsMoveRecord(buf []byte, offset flatbuffers.UOffsetT) *MoveRecord { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &MoveRecord{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedMoveRecordBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *MoveRecord) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *MoveRecord) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *MoveRecord) Player() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MoveRecord) MutatePlayer(n int32) bool { + return rcv._tab.MutateInt32Slot(4, n) +} + +func (rcv *MoveRecord) Action() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MoveRecord) Dir() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MoveRecord) MainRow() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MoveRecord) MutateMainRow(n int32) bool { + return rcv._tab.MutateInt32Slot(10, n) +} + +func (rcv *MoveRecord) MainCol() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MoveRecord) MutateMainCol(n int32) bool { + return rcv._tab.MutateInt32Slot(12, n) +} + +func (rcv *MoveRecord) Tiles(obj *TileRecord, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *MoveRecord) TilesLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *MoveRecord) Words(j int) []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4)) + } + return nil +} + +func (rcv *MoveRecord) WordsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *MoveRecord) Count() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MoveRecord) MutateCount(n int32) bool { + return rcv._tab.MutateInt32Slot(18, n) +} + +func (rcv *MoveRecord) Score() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MoveRecord) MutateScore(n int32) bool { + return rcv._tab.MutateInt32Slot(20, n) +} + +func (rcv *MoveRecord) Total() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MoveRecord) MutateTotal(n int32) bool { + return rcv._tab.MutateInt32Slot(22, n) +} + +func MoveRecordStart(builder *flatbuffers.Builder) { + builder.StartObject(10) +} +func MoveRecordAddPlayer(builder *flatbuffers.Builder, player int32) { + builder.PrependInt32Slot(0, player, 0) +} +func MoveRecordAddAction(builder *flatbuffers.Builder, action flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(action), 0) +} +func MoveRecordAddDir(builder *flatbuffers.Builder, dir flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(dir), 0) +} +func MoveRecordAddMainRow(builder *flatbuffers.Builder, mainRow int32) { + builder.PrependInt32Slot(3, mainRow, 0) +} +func MoveRecordAddMainCol(builder *flatbuffers.Builder, mainCol int32) { + builder.PrependInt32Slot(4, mainCol, 0) +} +func MoveRecordAddTiles(builder *flatbuffers.Builder, tiles flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(tiles), 0) +} +func MoveRecordStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func MoveRecordAddWords(builder *flatbuffers.Builder, words flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(words), 0) +} +func MoveRecordStartWordsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func MoveRecordAddCount(builder *flatbuffers.Builder, count int32) { + builder.PrependInt32Slot(7, count, 0) +} +func MoveRecordAddScore(builder *flatbuffers.Builder, score int32) { + builder.PrependInt32Slot(8, score, 0) +} +func MoveRecordAddTotal(builder *flatbuffers.Builder, total int32) { + builder.PrependInt32Slot(9, total, 0) +} +func MoveRecordEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/MoveResult.go b/pkg/fbs/scrabblefb/MoveResult.go new file mode 100644 index 0000000..a8e37f4 --- /dev/null +++ b/pkg/fbs/scrabblefb/MoveResult.go @@ -0,0 +1,81 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type MoveResult struct { + _tab flatbuffers.Table +} + +func GetRootAsMoveResult(buf []byte, offset flatbuffers.UOffsetT) *MoveResult { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &MoveResult{} + x.Init(buf, n+offset) + return x +} + +func FinishMoveResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsMoveResult(buf []byte, offset flatbuffers.UOffsetT) *MoveResult { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &MoveResult{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedMoveResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *MoveResult) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *MoveResult) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *MoveResult) Move(obj *MoveRecord) *MoveRecord { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(MoveRecord) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *MoveResult) Game(obj *GameView) *GameView { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(GameView) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func MoveResultStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func MoveResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(move), 0) +} +func MoveResultAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(game), 0) +} +func MoveResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/NudgeEvent.go b/pkg/fbs/scrabblefb/NudgeEvent.go new file mode 100644 index 0000000..9b4e907 --- /dev/null +++ b/pkg/fbs/scrabblefb/NudgeEvent.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type NudgeEvent struct { + _tab flatbuffers.Table +} + +func GetRootAsNudgeEvent(buf []byte, offset flatbuffers.UOffsetT) *NudgeEvent { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &NudgeEvent{} + x.Init(buf, n+offset) + return x +} + +func FinishNudgeEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsNudgeEvent(buf []byte, offset flatbuffers.UOffsetT) *NudgeEvent { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &NudgeEvent{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedNudgeEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *NudgeEvent) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *NudgeEvent) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *NudgeEvent) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *NudgeEvent) FromUserId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func NudgeEventStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func NudgeEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func NudgeEventAddFromUserId(builder *flatbuffers.Builder, fromUserId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(fromUserId), 0) +} +func NudgeEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/OpponentMovedEvent.go b/pkg/fbs/scrabblefb/OpponentMovedEvent.go new file mode 100644 index 0000000..6c9fa81 --- /dev/null +++ b/pkg/fbs/scrabblefb/OpponentMovedEvent.go @@ -0,0 +1,116 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type OpponentMovedEvent struct { + _tab flatbuffers.Table +} + +func GetRootAsOpponentMovedEvent(buf []byte, offset flatbuffers.UOffsetT) *OpponentMovedEvent { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &OpponentMovedEvent{} + x.Init(buf, n+offset) + return x +} + +func FinishOpponentMovedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsOpponentMovedEvent(buf []byte, offset flatbuffers.UOffsetT) *OpponentMovedEvent { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &OpponentMovedEvent{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedOpponentMovedEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *OpponentMovedEvent) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *OpponentMovedEvent) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *OpponentMovedEvent) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *OpponentMovedEvent) Seat() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *OpponentMovedEvent) MutateSeat(n int32) bool { + return rcv._tab.MutateInt32Slot(6, n) +} + +func (rcv *OpponentMovedEvent) Action() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *OpponentMovedEvent) Score() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *OpponentMovedEvent) MutateScore(n int32) bool { + return rcv._tab.MutateInt32Slot(10, n) +} + +func (rcv *OpponentMovedEvent) Total() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *OpponentMovedEvent) MutateTotal(n int32) bool { + return rcv._tab.MutateInt32Slot(12, n) +} + +func OpponentMovedEventStart(builder *flatbuffers.Builder) { + builder.StartObject(5) +} +func OpponentMovedEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func OpponentMovedEventAddSeat(builder *flatbuffers.Builder, seat int32) { + builder.PrependInt32Slot(1, seat, 0) +} +func OpponentMovedEventAddAction(builder *flatbuffers.Builder, action flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(action), 0) +} +func OpponentMovedEventAddScore(builder *flatbuffers.Builder, score int32) { + builder.PrependInt32Slot(3, score, 0) +} +func OpponentMovedEventAddTotal(builder *flatbuffers.Builder, total int32) { + builder.PrependInt32Slot(4, total, 0) +} +func OpponentMovedEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go new file mode 100644 index 0000000..6039eb2 --- /dev/null +++ b/pkg/fbs/scrabblefb/Profile.go @@ -0,0 +1,153 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type Profile struct { + _tab flatbuffers.Table +} + +func GetRootAsProfile(buf []byte, offset flatbuffers.UOffsetT) *Profile { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &Profile{} + x.Init(buf, n+offset) + return x +} + +func FinishProfileBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsProfile(buf []byte, offset flatbuffers.UOffsetT) *Profile { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &Profile{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedProfileBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *Profile) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *Profile) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *Profile) UserId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Profile) DisplayName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Profile) PreferredLanguage() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Profile) TimeZone() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Profile) HintBalance() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *Profile) MutateHintBalance(n int32) bool { + return rcv._tab.MutateInt32Slot(12, n) +} + +func (rcv *Profile) BlockChat() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *Profile) MutateBlockChat(n bool) bool { + return rcv._tab.MutateBoolSlot(14, n) +} + +func (rcv *Profile) BlockFriendRequests() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *Profile) MutateBlockFriendRequests(n bool) bool { + return rcv._tab.MutateBoolSlot(16, n) +} + +func (rcv *Profile) IsGuest() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *Profile) MutateIsGuest(n bool) bool { + return rcv._tab.MutateBoolSlot(18, n) +} + +func ProfileStart(builder *flatbuffers.Builder) { + builder.StartObject(8) +} +func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) +} +func ProfileAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(displayName), 0) +} +func ProfileAddPreferredLanguage(builder *flatbuffers.Builder, preferredLanguage flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(preferredLanguage), 0) +} +func ProfileAddTimeZone(builder *flatbuffers.Builder, timeZone flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(timeZone), 0) +} +func ProfileAddHintBalance(builder *flatbuffers.Builder, hintBalance int32) { + builder.PrependInt32Slot(4, hintBalance, 0) +} +func ProfileAddBlockChat(builder *flatbuffers.Builder, blockChat bool) { + builder.PrependBoolSlot(5, blockChat, false) +} +func ProfileAddBlockFriendRequests(builder *flatbuffers.Builder, blockFriendRequests bool) { + builder.PrependBoolSlot(6, blockFriendRequests, false) +} +func ProfileAddIsGuest(builder *flatbuffers.Builder, isGuest bool) { + builder.PrependBoolSlot(7, isGuest, false) +} +func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/SeatView.go b/pkg/fbs/scrabblefb/SeatView.go new file mode 100644 index 0000000..699fa73 --- /dev/null +++ b/pkg/fbs/scrabblefb/SeatView.go @@ -0,0 +1,120 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type SeatView struct { + _tab flatbuffers.Table +} + +func GetRootAsSeatView(buf []byte, offset flatbuffers.UOffsetT) *SeatView { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &SeatView{} + x.Init(buf, n+offset) + return x +} + +func FinishSeatViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsSeatView(buf []byte, offset flatbuffers.UOffsetT) *SeatView { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &SeatView{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedSeatViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *SeatView) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *SeatView) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *SeatView) Seat() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *SeatView) MutateSeat(n int32) bool { + return rcv._tab.MutateInt32Slot(4, n) +} + +func (rcv *SeatView) AccountId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *SeatView) Score() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *SeatView) MutateScore(n int32) bool { + return rcv._tab.MutateInt32Slot(8, n) +} + +func (rcv *SeatView) HintsUsed() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *SeatView) MutateHintsUsed(n int32) bool { + return rcv._tab.MutateInt32Slot(10, n) +} + +func (rcv *SeatView) IsWinner() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *SeatView) MutateIsWinner(n bool) bool { + return rcv._tab.MutateBoolSlot(12, n) +} + +func SeatViewStart(builder *flatbuffers.Builder) { + builder.StartObject(5) +} +func SeatViewAddSeat(builder *flatbuffers.Builder, seat int32) { + builder.PrependInt32Slot(0, seat, 0) +} +func SeatViewAddAccountId(builder *flatbuffers.Builder, accountId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(accountId), 0) +} +func SeatViewAddScore(builder *flatbuffers.Builder, score int32) { + builder.PrependInt32Slot(2, score, 0) +} +func SeatViewAddHintsUsed(builder *flatbuffers.Builder, hintsUsed int32) { + builder.PrependInt32Slot(3, hintsUsed, 0) +} +func SeatViewAddIsWinner(builder *flatbuffers.Builder, isWinner bool) { + builder.PrependBoolSlot(4, isWinner, false) +} +func SeatViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/Session.go b/pkg/fbs/scrabblefb/Session.go new file mode 100644 index 0000000..de425d8 --- /dev/null +++ b/pkg/fbs/scrabblefb/Session.go @@ -0,0 +1,97 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type Session struct { + _tab flatbuffers.Table +} + +func GetRootAsSession(buf []byte, offset flatbuffers.UOffsetT) *Session { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &Session{} + x.Init(buf, n+offset) + return x +} + +func FinishSessionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsSession(buf []byte, offset flatbuffers.UOffsetT) *Session { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &Session{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedSessionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *Session) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *Session) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *Session) Token() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Session) UserId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Session) IsGuest() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *Session) MutateIsGuest(n bool) bool { + return rcv._tab.MutateBoolSlot(8, n) +} + +func (rcv *Session) DisplayName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func SessionStart(builder *flatbuffers.Builder) { + builder.StartObject(4) +} +func SessionAddToken(builder *flatbuffers.Builder, token flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(token), 0) +} +func SessionAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(userId), 0) +} +func SessionAddIsGuest(builder *flatbuffers.Builder, isGuest bool) { + builder.PrependBoolSlot(2, isGuest, false) +} +func SessionAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(displayName), 0) +} +func SessionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/StateRequest.go b/pkg/fbs/scrabblefb/StateRequest.go new file mode 100644 index 0000000..db1badb --- /dev/null +++ b/pkg/fbs/scrabblefb/StateRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type StateRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsStateRequest(buf []byte, offset flatbuffers.UOffsetT) *StateRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &StateRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishStateRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsStateRequest(buf []byte, offset flatbuffers.UOffsetT) *StateRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &StateRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedStateRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *StateRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *StateRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *StateRequest) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func StateRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func StateRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func StateRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/StateView.go b/pkg/fbs/scrabblefb/StateView.go new file mode 100644 index 0000000..a37b943 --- /dev/null +++ b/pkg/fbs/scrabblefb/StateView.go @@ -0,0 +1,133 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type StateView struct { + _tab flatbuffers.Table +} + +func GetRootAsStateView(buf []byte, offset flatbuffers.UOffsetT) *StateView { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &StateView{} + x.Init(buf, n+offset) + return x +} + +func FinishStateViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsStateView(buf []byte, offset flatbuffers.UOffsetT) *StateView { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &StateView{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedStateViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *StateView) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *StateView) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *StateView) Game(obj *GameView) *GameView { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(GameView) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *StateView) Seat() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *StateView) MutateSeat(n int32) bool { + return rcv._tab.MutateInt32Slot(6, n) +} + +func (rcv *StateView) Rack(j int) []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4)) + } + return nil +} + +func (rcv *StateView) RackLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *StateView) BagLen() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *StateView) MutateBagLen(n int32) bool { + return rcv._tab.MutateInt32Slot(10, n) +} + +func (rcv *StateView) HintsRemaining() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *StateView) MutateHintsRemaining(n int32) bool { + return rcv._tab.MutateInt32Slot(12, n) +} + +func StateViewStart(builder *flatbuffers.Builder) { + builder.StartObject(5) +} +func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0) +} +func StateViewAddSeat(builder *flatbuffers.Builder, seat int32) { + builder.PrependInt32Slot(1, seat, 0) +} +func StateViewAddRack(builder *flatbuffers.Builder, rack flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(rack), 0) +} +func StateViewStartRackVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func StateViewAddBagLen(builder *flatbuffers.Builder, bagLen int32) { + builder.PrependInt32Slot(3, bagLen, 0) +} +func StateViewAddHintsRemaining(builder *flatbuffers.Builder, hintsRemaining int32) { + builder.PrependInt32Slot(4, hintsRemaining, 0) +} +func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/SubmitPlayRequest.go b/pkg/fbs/scrabblefb/SubmitPlayRequest.go new file mode 100644 index 0000000..49c1ed1 --- /dev/null +++ b/pkg/fbs/scrabblefb/SubmitPlayRequest.go @@ -0,0 +1,97 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type SubmitPlayRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsSubmitPlayRequest(buf []byte, offset flatbuffers.UOffsetT) *SubmitPlayRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &SubmitPlayRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishSubmitPlayRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsSubmitPlayRequest(buf []byte, offset flatbuffers.UOffsetT) *SubmitPlayRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &SubmitPlayRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedSubmitPlayRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *SubmitPlayRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *SubmitPlayRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *SubmitPlayRequest) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *SubmitPlayRequest) Dir() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *SubmitPlayRequest) Tiles(obj *TileRecord, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *SubmitPlayRequest) TilesLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func SubmitPlayRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(3) +} +func SubmitPlayRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func SubmitPlayRequestAddDir(builder *flatbuffers.Builder, dir flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(dir), 0) +} +func SubmitPlayRequestAddTiles(builder *flatbuffers.Builder, tiles flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(tiles), 0) +} +func SubmitPlayRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func SubmitPlayRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/TelegramLoginRequest.go b/pkg/fbs/scrabblefb/TelegramLoginRequest.go new file mode 100644 index 0000000..2dccaf3 --- /dev/null +++ b/pkg/fbs/scrabblefb/TelegramLoginRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type TelegramLoginRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsTelegramLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *TelegramLoginRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &TelegramLoginRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishTelegramLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsTelegramLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *TelegramLoginRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &TelegramLoginRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedTelegramLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *TelegramLoginRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *TelegramLoginRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *TelegramLoginRequest) InitData() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func TelegramLoginRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(initData), 0) +} +func TelegramLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/TileRecord.go b/pkg/fbs/scrabblefb/TileRecord.go new file mode 100644 index 0000000..f3d68ee --- /dev/null +++ b/pkg/fbs/scrabblefb/TileRecord.go @@ -0,0 +1,105 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type TileRecord struct { + _tab flatbuffers.Table +} + +func GetRootAsTileRecord(buf []byte, offset flatbuffers.UOffsetT) *TileRecord { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &TileRecord{} + x.Init(buf, n+offset) + return x +} + +func FinishTileRecordBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsTileRecord(buf []byte, offset flatbuffers.UOffsetT) *TileRecord { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &TileRecord{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedTileRecordBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *TileRecord) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *TileRecord) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *TileRecord) Row() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *TileRecord) MutateRow(n int32) bool { + return rcv._tab.MutateInt32Slot(4, n) +} + +func (rcv *TileRecord) Col() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *TileRecord) MutateCol(n int32) bool { + return rcv._tab.MutateInt32Slot(6, n) +} + +func (rcv *TileRecord) Letter() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *TileRecord) Blank() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *TileRecord) MutateBlank(n bool) bool { + return rcv._tab.MutateBoolSlot(10, n) +} + +func TileRecordStart(builder *flatbuffers.Builder) { + builder.StartObject(4) +} +func TileRecordAddRow(builder *flatbuffers.Builder, row int32) { + builder.PrependInt32Slot(0, row, 0) +} +func TileRecordAddCol(builder *flatbuffers.Builder, col int32) { + builder.PrependInt32Slot(1, col, 0) +} +func TileRecordAddLetter(builder *flatbuffers.Builder, letter flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(letter), 0) +} +func TileRecordAddBlank(builder *flatbuffers.Builder, blank bool) { + builder.PrependBoolSlot(3, blank, false) +} +func TileRecordEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/YourTurnEvent.go b/pkg/fbs/scrabblefb/YourTurnEvent.go new file mode 100644 index 0000000..def7be8 --- /dev/null +++ b/pkg/fbs/scrabblefb/YourTurnEvent.go @@ -0,0 +1,75 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type YourTurnEvent struct { + _tab flatbuffers.Table +} + +func GetRootAsYourTurnEvent(buf []byte, offset flatbuffers.UOffsetT) *YourTurnEvent { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &YourTurnEvent{} + x.Init(buf, n+offset) + return x +} + +func FinishYourTurnEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsYourTurnEvent(buf []byte, offset flatbuffers.UOffsetT) *YourTurnEvent { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &YourTurnEvent{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedYourTurnEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *YourTurnEvent) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *YourTurnEvent) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *YourTurnEvent) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *YourTurnEvent) DeadlineUnix() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *YourTurnEvent) MutateDeadlineUnix(n int64) bool { + return rcv._tab.MutateInt64Slot(6, n) +} + +func YourTurnEventStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func YourTurnEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func YourTurnEventAddDeadlineUnix(builder *flatbuffers.Builder, deadlineUnix int64) { + builder.PrependInt64Slot(1, deadlineUnix, 0) +} +func YourTurnEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/go.mod b/pkg/go.mod new file mode 100644 index 0000000..c1b54a7 --- /dev/null +++ b/pkg/go.mod @@ -0,0 +1,18 @@ +module scrabble/pkg + +go 1.26.3 + +require ( + github.com/google/flatbuffers v23.5.26+incompatible + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + golang.org/x/net v0.53.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 +) diff --git a/pkg/go.sum b/pkg/go.sum new file mode 100644 index 0000000..783494b --- /dev/null +++ b/pkg/go.sum @@ -0,0 +1,31 @@ +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/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= +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/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +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/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= diff --git a/pkg/proto/push/v1/push.pb.go b/pkg/proto/push/v1/push.pb.go new file mode 100644 index 0000000..45d49a3 --- /dev/null +++ b/pkg/proto/push/v1/push.pb.go @@ -0,0 +1,214 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: push/v1/push.proto + +// Package scrabble.push.v1 is the backend -> gateway live-event control channel. +// The gateway opens Subscribe once at startup and keeps the stream open; the +// backend pushes one Event per notification intent. The payload is an opaque +// FlatBuffers message (the scrabblefb.* tables) that the gateway forwards to the +// client without re-interpreting it. The transport is plain gRPC server-stream +// (ARCHITECTURE.md §2); the client edge is Connect-RPC (gateway/proto/edge). + +package pushv1 + +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) +) + +// SubscribeRequest opens a push subscription. gateway_id identifies the gateway +// instance for logging; the MVP backend keeps no per-gateway state. +type SubscribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + GatewayId string `protobuf:"bytes,1,opt,name=gateway_id,json=gatewayId,proto3" json:"gateway_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeRequest) Reset() { + *x = SubscribeRequest{} + mi := &file_push_v1_push_proto_msgTypes[0] + 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_push_v1_push_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 SubscribeRequest.ProtoReflect.Descriptor instead. +func (*SubscribeRequest) Descriptor() ([]byte, []int) { + return file_push_v1_push_proto_rawDescGZIP(), []int{0} +} + +func (x *SubscribeRequest) GetGatewayId() string { + if x != nil { + return x.GatewayId + } + return "" +} + +// Event is one live-event frame. kind is the notification catalog kind +// (your_turn, opponent_moved, chat_message, nudge, match_found). payload is the +// FlatBuffers-encoded body for that kind. event_id is a correlation id the +// gateway carries through to the client envelope. +type Event struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` + EventId string `protobuf:"bytes,4,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_push_v1_push_proto_msgTypes[1] + 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_push_v1_push_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 Event.ProtoReflect.Descriptor instead. +func (*Event) Descriptor() ([]byte, []int) { + return file_push_v1_push_proto_rawDescGZIP(), []int{1} +} + +func (x *Event) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +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_push_v1_push_proto protoreflect.FileDescriptor + +const file_push_v1_push_proto_rawDesc = "" + + "\n" + + "\x12push/v1/push.proto\x12\x10scrabble.push.v1\"1\n" + + "\x10SubscribeRequest\x12\x1d\n" + + "\n" + + "gateway_id\x18\x01 \x01(\tR\tgatewayId\"i\n" + + "\x05Event\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x18\n" + + "\apayload\x18\x03 \x01(\fR\apayload\x12\x19\n" + + "\bevent_id\x18\x04 \x01(\tR\aeventId2R\n" + + "\x04Push\x12J\n" + + "\tSubscribe\x12\".scrabble.push.v1.SubscribeRequest\x1a\x17.scrabble.push.v1.Event0\x01B#Z!scrabble/pkg/proto/push/v1;pushv1b\x06proto3" + +var ( + file_push_v1_push_proto_rawDescOnce sync.Once + file_push_v1_push_proto_rawDescData []byte +) + +func file_push_v1_push_proto_rawDescGZIP() []byte { + file_push_v1_push_proto_rawDescOnce.Do(func() { + file_push_v1_push_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_push_v1_push_proto_rawDesc), len(file_push_v1_push_proto_rawDesc))) + }) + return file_push_v1_push_proto_rawDescData +} + +var file_push_v1_push_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_push_v1_push_proto_goTypes = []any{ + (*SubscribeRequest)(nil), // 0: scrabble.push.v1.SubscribeRequest + (*Event)(nil), // 1: scrabble.push.v1.Event +} +var file_push_v1_push_proto_depIdxs = []int32{ + 0, // 0: scrabble.push.v1.Push.Subscribe:input_type -> scrabble.push.v1.SubscribeRequest + 1, // 1: scrabble.push.v1.Push.Subscribe:output_type -> scrabble.push.v1.Event + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] 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_push_v1_push_proto_init() } +func file_push_v1_push_proto_init() { + if File_push_v1_push_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_push_v1_push_proto_rawDesc), len(file_push_v1_push_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_push_v1_push_proto_goTypes, + DependencyIndexes: file_push_v1_push_proto_depIdxs, + MessageInfos: file_push_v1_push_proto_msgTypes, + }.Build() + File_push_v1_push_proto = out.File + file_push_v1_push_proto_goTypes = nil + file_push_v1_push_proto_depIdxs = nil +} diff --git a/pkg/proto/push/v1/push.proto b/pkg/proto/push/v1/push.proto new file mode 100644 index 0000000..40fbbb2 --- /dev/null +++ b/pkg/proto/push/v1/push.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +// Package scrabble.push.v1 is the backend -> gateway live-event control channel. +// The gateway opens Subscribe once at startup and keeps the stream open; the +// backend pushes one Event per notification intent. The payload is an opaque +// FlatBuffers message (the scrabblefb.* tables) that the gateway forwards to the +// client without re-interpreting it. The transport is plain gRPC server-stream +// (ARCHITECTURE.md §2); the client edge is Connect-RPC (gateway/proto/edge). +package scrabble.push.v1; + +option go_package = "scrabble/pkg/proto/push/v1;pushv1"; + +// Push is the unidirectional live-event channel from backend to gateway. +service Push { + // Subscribe opens the single backend -> gateway event stream. The backend + // sends every event for every user; the gateway fans them out to the active + // client subscriptions by user_id. + rpc Subscribe(SubscribeRequest) returns (stream Event); +} + +// SubscribeRequest opens a push subscription. gateway_id identifies the gateway +// instance for logging; the MVP backend keeps no per-gateway state. +message SubscribeRequest { + string gateway_id = 1; +} + +// Event is one live-event frame. kind is the notification catalog kind +// (your_turn, opponent_moved, chat_message, nudge, match_found). payload is the +// FlatBuffers-encoded body for that kind. event_id is a correlation id the +// gateway carries through to the client envelope. +message Event { + string user_id = 1; + string kind = 2; + bytes payload = 3; + string event_id = 4; +} diff --git a/pkg/proto/push/v1/push_grpc.pb.go b/pkg/proto/push/v1/push_grpc.pb.go new file mode 100644 index 0000000..c75a60b --- /dev/null +++ b/pkg/proto/push/v1/push_grpc.pb.go @@ -0,0 +1,141 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc (unknown) +// source: push/v1/push.proto + +// Package scrabble.push.v1 is the backend -> gateway live-event control channel. +// The gateway opens Subscribe once at startup and keeps the stream open; the +// backend pushes one Event per notification intent. The payload is an opaque +// FlatBuffers message (the scrabblefb.* tables) that the gateway forwards to the +// client without re-interpreting it. The transport is plain gRPC server-stream +// (ARCHITECTURE.md §2); the client edge is Connect-RPC (gateway/proto/edge). + +package pushv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Push_Subscribe_FullMethodName = "/scrabble.push.v1.Push/Subscribe" +) + +// PushClient is the client API for Push service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// Push is the unidirectional live-event channel from backend to gateway. +type PushClient interface { + // Subscribe opens the single backend -> gateway event stream. The backend + // sends every event for every user; the gateway fans them out to the active + // client subscriptions by user_id. + Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Event], error) +} + +type pushClient struct { + cc grpc.ClientConnInterface +} + +func NewPushClient(cc grpc.ClientConnInterface) PushClient { + return &pushClient{cc} +} + +func (c *pushClient) Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Event], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Push_ServiceDesc.Streams[0], Push_Subscribe_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SubscribeRequest, Event]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Push_SubscribeClient = grpc.ServerStreamingClient[Event] + +// PushServer is the server API for Push service. +// All implementations must embed UnimplementedPushServer +// for forward compatibility. +// +// Push is the unidirectional live-event channel from backend to gateway. +type PushServer interface { + // Subscribe opens the single backend -> gateway event stream. The backend + // sends every event for every user; the gateway fans them out to the active + // client subscriptions by user_id. + Subscribe(*SubscribeRequest, grpc.ServerStreamingServer[Event]) error + mustEmbedUnimplementedPushServer() +} + +// UnimplementedPushServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedPushServer struct{} + +func (UnimplementedPushServer) Subscribe(*SubscribeRequest, grpc.ServerStreamingServer[Event]) error { + return status.Errorf(codes.Unimplemented, "method Subscribe not implemented") +} +func (UnimplementedPushServer) mustEmbedUnimplementedPushServer() {} +func (UnimplementedPushServer) testEmbeddedByValue() {} + +// UnsafePushServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to PushServer will +// result in compilation errors. +type UnsafePushServer interface { + mustEmbedUnimplementedPushServer() +} + +func RegisterPushServer(s grpc.ServiceRegistrar, srv PushServer) { + // If the following call pancis, it indicates UnimplementedPushServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Push_ServiceDesc, srv) +} + +func _Push_Subscribe_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(SubscribeRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(PushServer).Subscribe(m, &grpc.GenericServerStream[SubscribeRequest, Event]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Push_SubscribeServer = grpc.ServerStreamingServer[Event] + +// Push_ServiceDesc is the grpc.ServiceDesc for Push service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Push_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "scrabble.push.v1.Push", + HandlerType: (*PushServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Subscribe", + Handler: _Push_Subscribe_Handler, + ServerStreams: true, + }, + }, + Metadata: "push/v1/push.proto", +}