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

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

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

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

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

go.work: use ./pkg, ./gateway + replace scrabble/pkg. CI: gateway/**, pkg/**
path filters; unit build/vet/test span all three modules. Docs (PLAN,
ARCHITECTURE, FUNCTIONAL+ru, TESTING, READMEs) updated; gateway/pkg unit tests +
guest/email-login integration tests.
This commit is contained in:
Ilia Denisov
2026-06-02 22:38:24 +02:00
parent 104eb2a978
commit 408da3f201
98 changed files with 8134 additions and 57 deletions
+7 -3
View File
@@ -9,6 +9,8 @@ on:
push: push:
paths: paths:
- 'backend/**' - 'backend/**'
- 'gateway/**'
- 'pkg/**'
- 'go.work' - 'go.work'
- 'go.work.sum' - 'go.work.sum'
- '.gitea/workflows/go-unit.yaml' - '.gitea/workflows/go-unit.yaml'
@@ -16,6 +18,8 @@ on:
pull_request: pull_request:
paths: paths:
- 'backend/**' - 'backend/**'
- 'gateway/**'
- 'pkg/**'
- 'go.work' - 'go.work'
- 'go.work.sum' - 'go.work.sum'
- '.gitea/workflows/go-unit.yaml' - '.gitea/workflows/go-unit.yaml'
@@ -52,10 +56,10 @@ jobs:
fi fi
- name: vet - name: vet
run: go vet ./backend/... run: go vet ./backend/... ./pkg/... ./gateway/...
- name: build - name: build
run: go build ./backend/... run: go build ./backend/... ./pkg/... ./gateway/...
- name: test - name: test
# -count=1 disables the test cache so a green run never depends on a # -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. # tests at the committed DAWGs in the sibling checkout.
env: env:
BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg
run: go test -count=1 ./backend/... run: go test -count=1 ./backend/... ./pkg/... ./gateway/...
+2
View File
@@ -11,6 +11,7 @@ on:
push: push:
paths: paths:
- 'backend/**' - 'backend/**'
- 'pkg/**'
- 'go.work' - 'go.work'
- 'go.work.sum' - 'go.work.sum'
- '.gitea/workflows/integration.yaml' - '.gitea/workflows/integration.yaml'
@@ -18,6 +19,7 @@ on:
pull_request: pull_request:
paths: paths:
- 'backend/**' - 'backend/**'
- 'pkg/**'
- 'go.work' - 'go.work'
- 'go.work.sum' - 'go.work.sum'
- '.gitea/workflows/integration.yaml' - '.gitea/workflows/integration.yaml'
+67 -1
View File
@@ -39,7 +39,7 @@ independent (see ARCHITECTURE §9.1).
| 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | **done** | | 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | **done** |
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** | | 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** |
| 5 | Robot opponent | **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 | | 7 | UI (plain Svelte + Vite, board, lobby, chat, i18n) | todo |
| 8 | Telegram integration (bot side-service, deep-link, push) | todo | | 8 | Telegram integration (bot side-service, deep-link, push) | todo |
| 9 | Admin & dictionary ops (complaint review, version reload) | 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 (10 s), `BACKEND_LOBBY_REAPER_INTERVAL` (1 s). No CI change (both Go workflows
already clone the solver sibling and export `BACKEND_DICT_DIR`). 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) ## Deferred TODOs (cross-stage)
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable, - **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 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 the runtime contract: a new `.dawg` appears in it and is loaded with
`dawg.Load`. `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.
+20 -7
View File
@@ -29,15 +29,18 @@ supports English Scrabble, Russian Scrabble and Эрудит.
## Build & test ## Build & test
```sh ```sh
go build ./backend/... # per module (the workspace spans several modules) go build ./backend/... ./pkg/... ./gateway/... # per module (the workspace spans several)
go vet ./backend/... go vet ./backend/... ./pkg/... ./gateway/...
gofmt -l . # must print nothing gofmt -l . # must print nothing
go test -count=1 ./backend/... # unit tests go test -count=1 ./backend/... ./pkg/... ./gateway/... # unit tests
go test -tags=integration -count=1 -p=1 ./backend/... # + Postgres (needs Docker) go test -tags=integration -count=1 -p=1 ./backend/... # + Postgres (needs Docker)
``` ```
The `integration`-tagged tests start a throwaway `postgres:17-alpine` container 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 ## Run the backend locally
@@ -47,7 +50,17 @@ migrations at startup:
```sh ```sh
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine 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' \ 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` Key environment: `BACKEND_HTTP_ADDR` (default `:8080`), `BACKEND_LOG_LEVEL`
+13 -1
View File
@@ -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 exposes `Poll` so a waiting player can collect the started game (the live
match-found notification arrives with the `gateway`). 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 ## Package layout
``` ```
@@ -80,7 +91,8 @@ internal/robot/ # human-like robot opponent: account pool, seed-derived str
| Variable | Default | Notes | | 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_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error`. |
| `BACKEND_POSTGRES_DSN` | — | **Required.** pgx/libpq URL; must pin `search_path=backend`. | | `BACKEND_POSTGRES_DSN` | — | **Required.** pgx/libpq URL; must pin `search_path=backend`. |
| `BACKEND_POSTGRES_MAX_OPEN_CONNS` | `25` | Pool max open connections. | | `BACKEND_POSTGRES_MAX_OPEN_CONNS` | `25` | Pool max open connections. |
+33 -1
View File
@@ -22,7 +22,9 @@ import (
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/postgres" "scrabble/backend/internal/postgres"
"scrabble/backend/internal/pushgrpc"
"scrabble/backend/internal/robot" "scrabble/backend/internal/robot"
"scrabble/backend/internal/server" "scrabble/backend/internal/server"
"scrabble/backend/internal/session" "scrabble/backend/internal/session"
@@ -58,6 +60,12 @@ func main() {
// turn-timeout sweeper), the robot opponent (pool + move driver) and the // turn-timeout sweeper), the robot opponent (pool + move driver) and the
// matchmaking reaper, HTTP server — and blocks until ctx is cancelled. // matchmaking reaper, HTTP server — and blocks until ctx is cancelled.
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { 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) tel, err := telemetry.New(ctx, cfg.Telemetry)
if err != nil { if err != nil {
return fmt.Errorf("init telemetry: %w", err) 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") 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) accounts := account.NewStore(db)
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger) games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
games.SetNotifier(hub)
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval) go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
logger.Info("game turn-timeout sweeper started", logger.Info("game turn-timeout sweeper started",
zap.Duration("interval", cfg.Game.TimeoutSweepInterval)) 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) mailer := newMailer(cfg.SMTP, logger)
emails := account.NewEmailService(accounts, mailer) emails := account.NewEmailService(accounts, mailer)
socialSvc := social.NewService(social.NewStore(db), accounts, games) socialSvc := social.NewService(social.NewStore(db), accounts, games)
socialSvc.SetNotifier(hub)
// Stage 5 robot opponent: provision its durable account pool (a hard startup // Stage 5 robot opponent: provision its durable account pool (a hard startup
// dependency, like the dictionaries) and start its move driver. The matchmaker // 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)) logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval))
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, logger) matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, logger)
matchmaker.SetNotifier(hub)
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval) go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc) invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait)) 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, DB: db,
PingTimeout: cfg.Postgres.OperationTimeout, PingTimeout: cfg.Postgres.OperationTimeout,
SessionsReady: sessions.Ready, SessionsReady: sessions.Ready,
Sessions: sessions,
Accounts: accounts,
Games: games,
Social: socialSvc, Social: socialSvc,
Matchmaker: matchmaker, Matchmaker: matchmaker,
Invitations: invitations, Invitations: invitations,
Emails: emails, 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 // newMailer builds the confirm-code mailer: an SMTP relay when a host is
+3
View File
@@ -54,6 +54,7 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // 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/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.14.3 // indirect github.com/jackc/pgconn v1.14.3 // indirect
github.com/jackc/pgio v1.0.0 // 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/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.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 google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
mvdan.cc/xurls/v2 v2.6.0 mvdan.cc/xurls/v2 v2.6.0
scrabble/pkg v0.0.0
) )
+33 -3
View File
@@ -1,6 +1,8 @@
// Package account owns durable internal accounts and their platform/email // Package account owns durable internal accounts and their platform/email
// identities. First contact from a platform auto-provisions an account bound to // 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 package account
import ( import (
@@ -52,8 +54,11 @@ type Account struct {
HintBalance int HintBalance int
BlockChat bool BlockChat bool
BlockFriendRequests bool BlockFriendRequests bool
CreatedAt time.Time // IsGuest marks an ephemeral guest account: a durable row with no identity,
UpdatedAt time.Time // 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. // 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 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 // 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 // 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. // 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), HintBalance: int(row.HintBalance),
BlockChat: row.BlockChat, BlockChat: row.BlockChat,
BlockFriendRequests: row.BlockFriendRequests, BlockFriendRequests: row.BlockFriendRequests,
IsGuest: row.IsGuest,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt, UpdatedAt: row.UpdatedAt,
} }
+94
View File
@@ -126,6 +126,72 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
return s.store.GetByID(ctx, accountID) 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. // emailConfirmation is a pending confirm-code row in domain form.
type emailConfirmation struct { type emailConfirmation struct {
id uuid.UUID id uuid.UUID
@@ -252,6 +318,34 @@ func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accoun
return nil 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. // normalizeEmail parses and lower-cases an email address, or returns ErrInvalidEmail.
func normalizeEmail(email string) (string, error) { func normalizeEmail(email string) (string, error) {
addr, err := mail.ParseAddress(strings.TrimSpace(email)) addr, err := mail.ParseAddress(strings.TrimSpace(email))
+8
View File
@@ -20,6 +20,9 @@ import (
type Config struct { type Config struct {
// HTTPAddr is the listen address of the HTTP listener (host:port). // HTTPAddr is the listen address of the HTTP listener (host:port).
HTTPAddr string 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 is the zap log level: "debug", "info", "warn" or "error".
LogLevel string LogLevel string
// Postgres configures the primary database pool. // Postgres configures the primary database pool.
@@ -40,6 +43,7 @@ type Config struct {
// Defaults applied when the corresponding environment variable is unset. // Defaults applied when the corresponding environment variable is unset.
const ( const (
defaultHTTPAddr = ":8080" defaultHTTPAddr = ":8080"
defaultGRPCAddr = ":9090"
defaultLogLevel = "info" defaultLogLevel = "info"
) )
@@ -100,6 +104,7 @@ func Load() (Config, error) {
c := Config{ c := Config{
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel), LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
Postgres: pg, Postgres: pg,
Telemetry: tel, Telemetry: tel,
@@ -124,6 +129,9 @@ func (c Config) validate() error {
if c.HTTPAddr == "" { if c.HTTPAddr == "" {
return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty") 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 { if err := c.Postgres.Validate(); err != nil {
return fmt.Errorf("config: %w (set BACKEND_POSTGRES_DSN)", err) return fmt.Errorf("config: %w (set BACKEND_POSTGRES_DSN)", err)
} }
+76 -2
View File
@@ -15,6 +15,7 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
) )
// Service is the game domain: it drives the engine over a single match, persists // Service is the game domain: it drives the engine over a single match, persists
@@ -31,6 +32,7 @@ type Service struct {
version string version string
clock func() time.Time clock func() time.Time
rng func() int64 rng func() int64
pub notify.Publisher
log *zap.Logger log *zap.Logger
} }
@@ -48,10 +50,23 @@ func NewService(store *Store, accounts *account.Store, registry *engine.Registry
version: cfg.DictVersion, version: cfg.DictVersion,
clock: clock, clock: clock,
rng: randomSeed, rng: randomSeed,
pub: notify.Nop{},
log: log, 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 // 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 // (seat 0 first), deals the racks, and warms the live-game cache. It validates
// the player count (24), the move clock, the hint allowance and that every seat // the player count (24), 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.endReason = "timeout"
} }
c.winner = g.Result().Winner 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 { if err := svc.store.CommitMove(ctx, c); err != nil {
svc.cache.remove(gameID) svc.cache.remove(gameID)
@@ -248,7 +268,43 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
if c.finished { if c.finished {
svc.cache.remove(gameID) 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, // 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 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. // seatNames resolves each seat's display name for GCG export.
func (svc *Service) seatNames(ctx context.Context, g Game) []string { func (svc *Service) seatNames(ctx context.Context, g Game) []string {
names := make([]string, g.Players) names := make([]string, g.Players)
+130
View File
@@ -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)
}
}
+25
View File
@@ -11,6 +11,7 @@ import (
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/notify"
) )
// Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs // Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs
@@ -30,6 +31,7 @@ type Matchmaker struct {
robots RobotProvider robots RobotProvider
waitDelay time.Duration waitDelay time.Duration
clock func() time.Time clock func() time.Time
pub notify.Publisher
log *zap.Logger log *zap.Logger
mu sync.Mutex mu sync.Mutex
@@ -51,6 +53,7 @@ func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Durat
robots: robots, robots: robots,
waitDelay: waitDelay, waitDelay: waitDelay,
clock: func() time.Time { return time.Now().UTC() }, clock: func() time.Time { return time.Now().UTC() },
pub: notify.Nop{},
log: log, log: log,
queues: make(map[engine.Variant][]uuid.UUID), queues: make(map[engine.Variant][]uuid.UUID),
queued: make(map[uuid.UUID]engine.Variant), 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 // EnqueueResult reports the outcome of joining the pool: either a started game or a
// queued ticket awaiting an opponent. // queued ticket awaiting an opponent.
type EnqueueResult struct { type EnqueueResult struct {
@@ -102,6 +125,7 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e
m.mu.Lock() m.mu.Lock()
m.results[opponent] = g m.results[opponent] = g
m.mu.Unlock() m.mu.Unlock()
m.emitMatchFound(g)
return EnqueueResult{Matched: true, Game: g}, nil 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.mu.Lock()
m.results[s.human] = g m.results[s.human] = g
m.mu.Unlock() m.mu.Unlock()
m.emitMatchFound(g)
} }
} }
+92
View File
@@ -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 ""
}
+110
View File
@@ -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)
}
}
+100
View File
@@ -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)
}
}
@@ -24,4 +24,5 @@ type Accounts struct {
AwayStart time.Time AwayStart time.Time
AwayEnd time.Time AwayEnd time.Time
HintBalance int32 HintBalance int32
IsGuest bool
} }
@@ -28,6 +28,7 @@ type accountsTable struct {
AwayStart postgres.ColumnTime AwayStart postgres.ColumnTime
AwayEnd postgres.ColumnTime AwayEnd postgres.ColumnTime
HintBalance postgres.ColumnInteger HintBalance postgres.ColumnInteger
IsGuest postgres.ColumnBool
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -80,9 +81,10 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
AwayStartColumn = postgres.TimeColumn("away_start") AwayStartColumn = postgres.TimeColumn("away_start")
AwayEndColumn = postgres.TimeColumn("away_end") AwayEndColumn = postgres.TimeColumn("away_end")
HintBalanceColumn = postgres.IntegerColumn("hint_balance") HintBalanceColumn = postgres.IntegerColumn("hint_balance")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn} IsGuestColumn = postgres.BoolColumn("is_guest")
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn} allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn} 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{ return accountsTable{
@@ -100,6 +102,7 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
AwayStart: AwayStartColumn, AwayStart: AwayStartColumn,
AwayEnd: AwayEndColumn, AwayEnd: AwayEndColumn,
HintBalance: HintBalanceColumn, HintBalance: HintBalanceColumn,
IsGuest: IsGuestColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -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;
+107
View File
@@ -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
}
}
+250
View File
@@ -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
}
}
+113
View File
@@ -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)
}
}
+133
View File
@@ -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"
}
}
+16
View File
@@ -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"})
}
+134
View File
@@ -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))
}
+82
View File
@@ -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)
}
}
+168
View File
@@ -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))
}
+16 -4
View File
@@ -18,7 +18,9 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/session"
"scrabble/backend/internal/social" "scrabble/backend/internal/social"
"scrabble/backend/internal/telemetry" "scrabble/backend/internal/telemetry"
) )
@@ -42,10 +44,13 @@ type Deps struct {
// SessionsReady reports whether the session cache has been warmed. A nil // SessionsReady reports whether the session cache has been warmed. A nil
// func skips the session-readiness check. // func skips the session-readiness check.
SessionsReady func() bool SessionsReady func() bool
// Social, Matchmaker, Invitations and Emails are the Stage 4 domain services. // Sessions, Accounts and Games are the identity, account and game-domain
// They are held for the REST/stream handlers the gateway adds in Stage 6 (like // services the Stage 6 REST handlers route to.
// the route groups, this is scaffolding exposed via accessors); the server Sessions *session.Service
// itself does not route to them yet. 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 Social *social.Service
Matchmaker *lobby.Matchmaker Matchmaker *lobby.Matchmaker
Invitations *lobby.InvitationService Invitations *lobby.InvitationService
@@ -61,6 +66,9 @@ type Server struct {
pingTimeout time.Duration pingTimeout time.Duration
sessionsReady func() bool sessionsReady func() bool
sessions *session.Service
accounts *account.Store
games *game.Service
social *social.Service social *social.Service
matchmaker *lobby.Matchmaker matchmaker *lobby.Matchmaker
invitations *lobby.InvitationService invitations *lobby.InvitationService
@@ -94,6 +102,9 @@ func New(addr string, deps Deps) *Server {
db: deps.DB, db: deps.DB,
pingTimeout: pingTimeout, pingTimeout: pingTimeout,
sessionsReady: deps.SessionsReady, sessionsReady: deps.SessionsReady,
sessions: deps.Sessions,
accounts: deps.Accounts,
games: deps.Games,
social: deps.Social, social: deps.Social,
matchmaker: deps.Matchmaker, matchmaker: deps.Matchmaker,
invitations: deps.Invitations, invitations: deps.Invitations,
@@ -102,6 +113,7 @@ func New(addr string, deps Deps) *Server {
} }
s.registerProbes(engine) s.registerProbes(engine)
s.registerAPIGroups(engine) s.registerAPIGroups(engine)
s.registerRoutes()
return s return s
} }
+28 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/go-jet/jet/v2/qrm" "github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid" "github.com/google/uuid"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table" "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 { if err := Clean(body); err != nil {
return Message{}, err 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 // 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 { if ok && svc.now().Sub(last) < nudgeInterval {
return Message{}, ErrNudgeTooSoon 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 // LastNudgeAt returns the time of the most recent nudge senderID sent in the game
+12
View File
@@ -15,6 +15,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
) )
// GameReader is the slice of the game domain the social package needs: the seated // GameReader is the slice of the game domain the social package needs: the seated
@@ -60,6 +61,7 @@ type Service struct {
store *Store store *Store
accounts *account.Store accounts *account.Store
games GameReader games GameReader
pub notify.Publisher
now func() time.Time now func() time.Time
} }
@@ -70,6 +72,16 @@ func NewService(store *Store, accounts *account.Store, games GameReader) *Servic
store: store, store: store,
accounts: accounts, accounts: accounts,
games: games, games: games,
pub: notify.Nop{},
now: func() time.Time { return time.Now().UTC() }, 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
}
}
+52 -26
View File
@@ -11,12 +11,13 @@ not-yet-implemented components are marked *(planned)*.
Three executables plus per-platform side-services: Three executables plus per-platform side-services:
- **`gateway`** *(planned)* — the only public ingress. Performs anti-abuse - **`gateway`** — the only public ingress (module `scrabble/gateway`). Performs
(rate limiting), authenticates the player against the originating platform anti-abuse (rate limiting), authenticates the player against the originating
(or an email/guest session), resolves the internal `user_id`, and forwards platform (or an email/guest session), resolves the internal `user_id`, and
authenticated traffic to `backend` with an `X-User-ID` header. Hosts an admin forwards authenticated traffic to `backend` with an `X-User-ID` header. Hosts an
surface behind HTTP Basic Auth. Bridges live events from `backend` to the admin surface behind HTTP Basic Auth. Bridges live events from `backend` to the
client. 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: - **`backend`** — internal-only service that owns every domain concern:
identity/sessions, accounts and linking, lobby and matchmaking, the game identity/sessions, accounts and linking, lobby and matchmaking, the game
runtime, the robot opponent, chat, notifications, statistics, history, and 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 - **client ↔ gateway**: **Connect-RPC + FlatBuffers** over HTTP/2 cleartext
(`h2c`). Binary payloads, server-streaming for the in-app live channel, (`h2c`). Binary payloads, server-streaming for the in-app live channel,
first-class JS clients (`@connectrpc/connect-web` + the `flatbuffers` npm 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 - **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
`X-User-ID` for authenticated requests; `backend` never re-derives identity `X-User-ID` for authenticated requests; `backend` never re-derives identity
from the body. 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 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 resolution, and treats sessions as **revoke-only** — they have no TTL and live
until explicitly revoked (`status``revoked`). until explicitly revoked (`status``revoked`).
- **Guest** = ephemeral web session (no platform, no email): session-only, - **Guest** = ephemeral web session (no platform, no email). A guest is backed by
nothing persisted; restricted to auto-match, with no friends and no a durable `accounts` row flagged `is_guest` and carrying **no identity** — the
stats/history. Platform users are auto-provisioned **durable** accounts. 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 ## 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 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 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 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 robot (§7) and starts the game. On a pairing or substitution the matchmaker
substitution through the matchmaker's `Poll`, the interim delivery seam until the emits a **match-found** notification (§10), delivered over the live stream;
live match-found notification (§10). `Poll` remains as a fallback for a client that is not currently streaming.
- **Friends**: a **request → accept** graph (one `friendships` table) — add by - **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 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 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 Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary
keys are application-generated **UUIDv7**. keys are application-generated **UUIDv7**.
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window - 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)`; `identities` (platform/email/robot identities, unique `(kind, external_id)`;
Stage 5's migration `00004` admits the `robot` kind), Stage 5's migration `00004` admits the `robot` kind),
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables `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 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 live game so the next access rebuilds from the journal. `game_players` records
each seat's account, running score, hints used and winner flag. each seat's account, running score, hints used and winner flag.
- **Statistics** (`account_stats`, recomputed on each finish, durable accounts - **Statistics** (`account_stats`, recomputed on each finish for durable
only — guests never appear): wins, losses, **draws**, max points in a game, and 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 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 formed plus the all-tiles bonus). A tie increments draws only; a resignation or
timeout is a loss for the acting player. timeout is a loss for the acting player.
@@ -319,15 +336,21 @@ does not cover.
## 10. Notifications ## 10. Notifications
Two channels: **platform-native push** (out-of-app, via the platform Two channels: the **in-app live stream** (delivered from Stage 6) and
side-service — your-turn, nudge) and the **in-app live stream** (chat, **platform-native push** (out-of-app, via the platform side-service — Stage 8).
opponent-moved, while the app is open). Backend emits notification intents; The backend emits notification intents through an in-process hub
delivery fans out to the appropriate channel. A **match-found** event (a human (`internal/notify`, a `Publisher` seam installed on the game, social and lobby
pairing or a robot substitution in auto-match, §8) belongs to the same fabric. services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
Stage 4 **persists** the notification-worthy events (chat messages and nudges) but `pkg/proto/push/v1`) carries every event, and the gateway fans them out by
does not yet deliver them, and Stage 5's match-found has no live channel yet: the `user_id` to each client's Connect `Subscribe` stream while the app is open. The
gRPC stream to the gateway and the platform push arrive in Stage 6 / 8. Until then catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so
a waiting client retrieves its started game by polling the matchmaker (`Poll`). 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 ## 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. client-measured RTT piggybacked on the next request is a later enhancement.
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the - Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
database answers a bounded ping and the session cache is warmed). 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 ## 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 | | Platform credential validation, session minting | gateway |
| Session → `user_id` resolution, `X-User-ID` injection | gateway | | Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) | | 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) | | backend ↔ gateway trust | the network (only gateway may reach backend) |
This is an explicit, accepted MVP risk: compromise of the gateway↔backend This is an explicit, accepted MVP risk: compromise of the gateway↔backend
+4 -1
View File
@@ -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 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 token; the backend resolves it to an internal `user_id`. Guests are
session-only with restricted features (auto-match only; no friends, stats or 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)* ### Accounts, linking & merge *(Stage 1 / 10)*
First platform contact auto-provisions a durable account. From the profile a First platform contact auto-provisions a durable account. From the profile a
+4 -1
View File
@@ -13,7 +13,10 @@
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
session-токен; backend сопоставляет его с внутренним `user_id`. Гость — session-токен; backend сопоставляет его с внутренним `user_id`. Гость —
только сессия, с урезанными функциями (только авто-подбор; без друзей, только сессия, с урезанными функциями (только авто-подбор; без друзей,
статистики и истории). статистики и истории). Пока приложение открыто, клиент держит живой стрим и
получает обновления в реальном времени — ход соперника, ваш ход, чат, nudge и
найденный матч; внеприложенческий push (ваш ход, nudge) платформа доставит
позже (Stage 8).
### Аккаунты, привязка и слияние *(Stage 1 / 10)* ### Аккаунты, привязка и слияние *(Stage 1 / 10)*
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
+16 -1
View File
@@ -7,7 +7,8 @@ tests or touching CI.
- **Go unit tests** — table-driven where it helps; `testing` + standard library. - **Go unit tests** — table-driven where it helps; `testing` + standard library.
Every functional change ships with regression coverage. Run: 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` - **Integration** *(Stage 1+)* — Postgres-backed tests behind the `integration`
build tag spin a throwaway `postgres:17-alpine` via `testcontainers-go`. They build tag spin a throwaway `postgres:17-alpine` via `testcontainers-go`. They
live in `backend/internal/inttest` and run with 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 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 → statistics row), the matchmaker substitution end-to-end (enqueue → reap →
`[human, robot]`, discoverable via `Poll`), and a proactive 12-hour nudge. `[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 ## Principles
+24
View File
@@ -0,0 +1,24 @@
# Code generation for the gateway's Connect edge contract. The generated Go is
# COMMITTED; CI only builds it (the same dev-time model as backend/cmd/jetgen and
# pkg/Makefile). The FlatBuffers payloads live in pkg (generate them with
# `make -C ../pkg fbs`).
#
# Prerequisites:
# make tools # go install the local protoc-gen-* plugins
# Then:
# make gen # buf generate (protobuf-go + connect-go)
.PHONY: gen tools
GOBIN := $(shell go env GOBIN)
ifeq ($(GOBIN),)
GOBIN := $(shell go env GOPATH)/bin
endif
# tools installs the local buf plugins, pinned to the connect-go and protobuf
# runtime versions in go.mod.
tools:
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11
go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.19.2
gen:
PATH="$(GOBIN):$$PATH" buf generate
+95
View File
@@ -0,0 +1,95 @@
# gateway
The Scrabble platform's only public ingress (module `scrabble/gateway`). It
terminates the client's **Connect-RPC + FlatBuffers** traffic over HTTP/2
cleartext (`h2c`), authenticates the originating credential, mints/resolves a
thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
backend over REST/JSON, and bridges the backend's gRPC push stream to each
client's in-app live channel. It also fronts the backend admin API behind HTTP
Basic-Auth. See [`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10,
§12.
## Package layout
```
cmd/gateway/ # main: config -> backend client -> session cache ->
# push hub -> Connect h2c server (+ admin) -> serve
proto/edge/v1/ # Connect envelope contract (committed generated Go)
internal/config/ # GATEWAY_* env config
internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client
internal/session/ # in-memory session cache (LRU/TTL, backend fallback)
internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate)
internal/auth/ # Telegram initData HMAC validator (seam + fixtures)
internal/push/ # live-event fan-out hub (per-user client streams)
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
internal/connectsrv/ # the Connect Gateway service over h2c
internal/admin/ # Basic-Auth reverse proxy to the backend admin API
```
The FlatBuffers payloads and the backend push proto are the shared wire
contracts in [`../pkg`](../pkg).
## Transport contract
A single `Gateway` Connect service: `Execute(message_type, payload, request_id)`
for unary operations and `Subscribe` for the live stream. The `payload` bytes are
FlatBuffers tables (`scrabble/pkg/fbs`); the gateway transcodes them to and from
the backend's JSON. The session token rides in `Authorization: Bearer`; `auth.*`
operations are unauthenticated and return the minted token. A unary domain
outcome rides back in `ExecuteResponse.result_code` (HTTP 200); only edge
failures become Connect error codes.
The Stage 6 message-type slice: `auth.telegram`, `auth.guest`,
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events
`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found`. Further
operations follow the same transcode pattern (added in Stage 7).
## Configuration
| Variable | Default | Notes |
| --- | --- | --- |
| `GATEWAY_HTTP_ADDR` | `:8081` | public Connect/h2c listener |
| `GATEWAY_ADMIN_ADDR` | `:8082` | admin proxy listener (enabled only with creds) |
| `GATEWAY_LOG_LEVEL` | `info` | zap level |
| `GATEWAY_BACKEND_HTTP_URL` | `http://localhost:8080` | backend REST base URL |
| `GATEWAY_BACKEND_GRPC_ADDR` | `localhost:9090` | backend push gRPC address |
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin proxy |
| `GATEWAY_TELEGRAM_BOT_TOKEN` | unset | enable the Telegram auth path |
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `15s` | live-stream keep-alive |
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated
120/min·user (burst 40), admin 60/min·IP (burst 20), email-code 5/10 min·IP.
## Run
```sh
GATEWAY_BACKEND_HTTP_URL=http://localhost:8080 \
GATEWAY_BACKEND_GRPC_ADDR=localhost:9090 \
go run ./gateway/cmd/gateway # Connect edge on :8081
```
## Generated code
The Connect envelope Go is committed under `proto/edge/v1`. Regenerate after
editing the `.proto` (dev-time, like `backend/cmd/jetgen`):
```sh
make -C gateway tools # go install protoc-gen-go + protoc-gen-connect-go
make -C gateway gen # buf generate (local plugins)
```
The FlatBuffers payloads are generated in [`../pkg`](../pkg) (`make -C pkg fbs`).
## Tests
```sh
go test -count=1 ./gateway/...
```
All gateway tests are hermetic: no real network, a fake backend (`httptest`) and
credential fixtures. There is no integration (Docker) suite — the gateway holds
no database.
+13
View File
@@ -0,0 +1,13 @@
version: v2
# Local plugins (go install via `make tools`) so generation needs no buf.build
# remote fetch. Output is committed; CI only builds it.
plugins:
- local: protoc-gen-go
out: proto
opt:
- paths=source_relative
- local: protoc-gen-connect-go
out: proto
opt:
- paths=source_relative
+9
View File
@@ -0,0 +1,9 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
+210
View File
@@ -0,0 +1,210 @@
// Command gateway is the Scrabble platform's only public ingress. It terminates
// the client's Connect-RPC/FlatBuffers traffic over h2c, validates platform /
// email / guest credentials and mints opaque sessions, rate-limits, injects
// X-User-ID when forwarding to the backend over REST, and bridges the backend's
// gRPC push stream to each client's in-app live channel. It also fronts the
// backend admin API behind HTTP Basic-Auth.
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
"go.uber.org/zap"
"scrabble/gateway/internal/admin"
"scrabble/gateway/internal/auth"
"scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/connectsrv"
"scrabble/gateway/internal/push"
"scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode"
)
const (
// shutdownTimeout bounds the graceful HTTP shutdown.
shutdownTimeout = 10 * time.Second
// pushReconnectDelay is the pause before re-subscribing to the backend push
// stream after it ends.
pushReconnectDelay = 2 * time.Second
// gatewayID identifies this gateway instance to the backend push channel.
gatewayID = "gateway"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("gateway: load config: %v", err)
}
logger, err := newLogger(cfg.LogLevel)
if err != nil {
log.Fatalf("gateway: build logger: %v", err)
}
defer func() { _ = logger.Sync() }()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx, cfg, logger); err != nil {
logger.Fatal("gateway: terminated", zap.Error(err))
}
}
// run wires the gateway dependencies and serves the public (and optional admin)
// listeners until the context is cancelled.
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
backend, err := backendclient.New(cfg.BackendHTTPURL, cfg.BackendGRPCAddr, cfg.BackendTimeout)
if err != nil {
return err
}
defer func() { _ = backend.Close() }()
sessions := session.NewCache(backend, cfg.SessionTTL, cfg.SessionCacheMax)
limiter := ratelimit.New()
hub := push.NewHub(0)
var tg auth.TelegramValidator
if cfg.TelegramBotToken != "" {
tg = auth.NewHMACValidator(cfg.TelegramBotToken)
} else {
logger.Warn("telegram auth disabled (GATEWAY_TELEGRAM_BOT_TOKEN unset)")
}
registry := transcode.NewRegistry(backend, tg)
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry,
Sessions: sessions,
Limiter: limiter,
Hub: hub,
RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval,
Logger: logger,
})
// Bridge the backend push stream into the fan-out hub.
go runPushPump(ctx, backend, hub, logger)
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler()}
servers := []*namedServer{{name: "public", srv: public}}
if cfg.AdminEnabled() {
proxy, err := admin.NewProxy(cfg.BackendHTTPURL, cfg.AdminUser, cfg.AdminPassword, logger)
if err != nil {
return err
}
servers = append(servers, &namedServer{name: "admin", srv: &http.Server{Addr: cfg.AdminAddr, Handler: proxy}})
} else {
logger.Info("admin proxy disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
}
logger.Info("gateway starting",
zap.String("http_addr", cfg.HTTPAddr),
zap.String("backend_http", cfg.BackendHTTPURL),
zap.String("backend_grpc", cfg.BackendGRPCAddr))
return runServers(ctx, cancel, servers, logger)
}
// namedServer pairs an HTTP server with a label for diagnostics.
type namedServer struct {
name string
srv *http.Server
}
// runServers serves every listener and shuts them all down when the first one
// stops or the context is cancelled.
func runServers(ctx context.Context, cancel context.CancelFunc, servers []*namedServer, logger *zap.Logger) error {
errc := make(chan error, len(servers))
for _, s := range servers {
go func(s *namedServer) {
logger.Info("listener starting", zap.String("server", s.name), zap.String("addr", s.srv.Addr))
err := s.srv.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
errc <- err
}(s)
}
var first error
select {
case <-ctx.Done():
case first = <-errc:
}
cancel()
shutdownCtx, sc := context.WithTimeout(context.Background(), shutdownTimeout)
defer sc()
for _, s := range servers {
if err := s.srv.Shutdown(shutdownCtx); err != nil {
logger.Warn("listener shutdown", zap.String("server", s.name), zap.Error(err))
}
}
return first
}
// runPushPump keeps a backend push subscription open, forwarding every event to
// the hub and re-subscribing after the stream ends, until the context is done.
func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.Hub, logger *zap.Logger) {
for ctx.Err() == nil {
stream, err := backend.SubscribePush(ctx, gatewayID)
if err != nil {
logger.Warn("push subscribe failed", zap.Error(err))
if !sleep(ctx, pushReconnectDelay) {
return
}
continue
}
for {
ev, err := stream.Recv()
if err != nil {
if ctx.Err() == nil {
logger.Warn("push stream ended", zap.Error(err))
}
break
}
hub.Publish(push.Event{
UserID: ev.GetUserId(),
Kind: ev.GetKind(),
Payload: ev.GetPayload(),
EventID: ev.GetEventId(),
})
}
if !sleep(ctx, pushReconnectDelay) {
return
}
}
}
// sleep waits for d or until ctx is cancelled, reporting whether it waited the
// full duration.
func sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// newLogger builds a production JSON logger at the given level.
func newLogger(level string) (*zap.Logger, error) {
var lvl zap.AtomicLevel
if err := lvl.UnmarshalText([]byte(level)); err != nil {
return nil, err
}
cfg := zap.NewProductionConfig()
cfg.Level = lvl
return cfg.Build()
}
+21
View File
@@ -0,0 +1,21 @@
module scrabble/gateway
go 1.26.3
require (
connectrpc.com/connect v1.19.2
github.com/google/flatbuffers v23.5.26+incompatible
go.uber.org/zap v1.27.1
golang.org/x/net v0.53.0
golang.org/x/time v0.15.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
scrabble/pkg v0.0.0
)
require (
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
)
+58
View File
@@ -0,0 +1,58 @@
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg=
github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+64
View File
@@ -0,0 +1,64 @@
// Package admin is the gateway's admin surface: HTTP Basic-Auth in front of a
// reverse proxy to the backend admin API (docs/ARCHITECTURE.md §12). The gateway
// validates the operator credential and forwards authenticated requests to
// backend /api/v1/admin/*; the backend trusts the gateway on this segment. The
// admin API itself is filled in Stage 9.
package admin
import (
"crypto/subtle"
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"go.uber.org/zap"
)
// backendAdminPrefix is where the backend mounts its admin API.
const backendAdminPrefix = "/api/v1/admin"
// NewProxy returns a handler that checks Basic-Auth against user/password and
// reverse-proxies the request to the backend admin API, mapping an inbound
// /admin/<rest> path to <backendURL>/api/v1/admin/<rest>.
func NewProxy(backendURL, user, password string, log *zap.Logger) (http.Handler, error) {
target, err := url.Parse(backendURL)
if err != nil {
return nil, fmt.Errorf("admin: parse backend url %q: %w", backendURL, err)
}
if log == nil {
log = zap.NewNop()
}
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(target)
rel := strings.TrimPrefix(pr.In.URL.Path, "/admin")
pr.Out.URL.Path = backendAdminPrefix + rel
pr.Out.Host = pr.In.Host
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
log.Warn("admin proxy upstream error", zap.String("path", r.URL.Path), zap.Error(err))
w.WriteHeader(http.StatusBadGateway)
},
}
return basicAuth(user, password, proxy), nil
}
// basicAuth wraps next with a constant-time Basic-Auth check.
func basicAuth(user, password string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || !equal(u, user) || !equal(p, password) {
w.Header().Set("WWW-Authenticate", `Basic realm="scrabble-admin"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// equal compares two strings in constant time.
func equal(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
+73
View File
@@ -0,0 +1,73 @@
package admin_test
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"scrabble/gateway/internal/admin"
)
func newAdmin(t *testing.T) (*httptest.Server, func()) {
t.Helper()
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/admin/ping" {
t.Errorf("backend path = %q, want /api/v1/admin/ping", r.URL.Path)
}
_, _ = w.Write([]byte("pong"))
}))
proxy, err := admin.NewProxy(backend.URL, "ops", "secret", nil)
if err != nil {
t.Fatalf("new proxy: %v", err)
}
front := httptest.NewServer(proxy)
return front, func() { front.Close(); backend.Close() }
}
func TestAdminRejectsMissingCredentials(t *testing.T) {
front, cleanup := newAdmin(t)
defer cleanup()
resp, err := http.Get(front.URL + "/admin/ping")
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
func TestAdminProxiesWithCredentials(t *testing.T) {
front, cleanup := newAdmin(t)
defer cleanup()
req, _ := http.NewRequest(http.MethodGet, front.URL+"/admin/ping", nil)
req.SetBasicAuth("ops", "secret")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK || string(body) != "pong" {
t.Fatalf("status = %d body = %q, want 200 pong", resp.StatusCode, body)
}
}
func TestAdminRejectsWrongPassword(t *testing.T) {
front, cleanup := newAdmin(t)
defer cleanup()
req, _ := http.NewRequest(http.MethodGet, front.URL+"/admin/ping", nil)
req.SetBasicAuth("ops", "wrong")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
+139
View File
@@ -0,0 +1,139 @@
// Package auth holds the gateway's credential validators. The only non-trivial
// one is the Telegram Web App initData HMAC check; guest and email logins carry
// no gateway-side secret and are validated by the backend. The validator is an
// interface so handlers test against fixtures without a bot token.
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
// the hash, is malformed, or is older than the freshness window.
var ErrInvalidInitData = errors.New("auth: invalid telegram init data")
// defaultMaxAge bounds how old a validated initData payload may be.
const defaultMaxAge = 24 * time.Hour
// TelegramUser is the identity extracted from a validated initData payload. ID
// is the platform user id used as the identity's external_id.
type TelegramUser struct {
ID string
Username string
FirstName string
}
// TelegramValidator validates Telegram Web App launch data and returns the
// authenticated user.
type TelegramValidator interface {
Validate(initData string) (TelegramUser, error)
}
// HMACValidator validates initData against a bot token per Telegram's documented
// algorithm: the data-check string is HMAC-SHA256'd under a secret derived from
// the bot token, and the result is compared with the supplied hash.
type HMACValidator struct {
botToken string
maxAge time.Duration
now func() time.Time
}
// NewHMACValidator constructs a validator for botToken.
func NewHMACValidator(botToken string) *HMACValidator {
return &HMACValidator{botToken: botToken, maxAge: defaultMaxAge, now: time.Now}
}
// Validate parses and verifies initData, returning the authenticated user.
func (v *HMACValidator) Validate(initData string) (TelegramUser, error) {
values, err := url.ParseQuery(initData)
if err != nil {
return TelegramUser{}, ErrInvalidInitData
}
hash := values.Get("hash")
if hash == "" {
return TelegramUser{}, ErrInvalidInitData
}
values.Del("hash")
if !v.checkSignature(values, hash) {
return TelegramUser{}, ErrInvalidInitData
}
if err := v.checkFreshness(values.Get("auth_date")); err != nil {
return TelegramUser{}, err
}
return parseUser(values.Get("user"))
}
// checkSignature recomputes the HMAC over the sorted data-check string and
// compares it with hash in constant time.
func (v *HMACValidator) checkSignature(values url.Values, hash string) bool {
keys := make([]string, 0, len(values))
for k := range values {
keys = append(keys, k)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, k := range keys {
lines = append(lines, k+"="+values.Get(k))
}
dataCheck := strings.Join(lines, "\n")
secret := hmacSHA256([]byte("WebAppData"), []byte(v.botToken))
want := hmacSHA256(secret, []byte(dataCheck))
got, err := hex.DecodeString(hash)
if err != nil {
return false
}
return hmac.Equal(want, got)
}
// checkFreshness rejects an auth_date older than the validator's window.
func (v *HMACValidator) checkFreshness(authDate string) error {
if authDate == "" {
return ErrInvalidInitData
}
secs, err := strconv.ParseInt(authDate, 10, 64)
if err != nil {
return ErrInvalidInitData
}
if v.now().Sub(time.Unix(secs, 0)) > v.maxAge {
return ErrInvalidInitData
}
return nil
}
// parseUser extracts the user id and names from the user JSON field.
func parseUser(userJSON string) (TelegramUser, error) {
if userJSON == "" {
return TelegramUser{}, ErrInvalidInitData
}
var u struct {
ID int64 `json:"id"`
Username string `json:"username"`
FirstName string `json:"first_name"`
}
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
return TelegramUser{}, ErrInvalidInitData
}
return TelegramUser{
ID: strconv.FormatInt(u.ID, 10),
Username: u.Username,
FirstName: u.FirstName,
}, nil
}
// hmacSHA256 returns HMAC-SHA256(message) under key.
func hmacSHA256(key, message []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(message)
return h.Sum(nil)
}
+92
View File
@@ -0,0 +1,92 @@
package auth_test
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/url"
"sort"
"strconv"
"strings"
"testing"
"time"
"scrabble/gateway/internal/auth"
)
// signedInitData builds a valid Telegram initData query string for botToken,
// computing the hash exactly as Telegram does.
func signedInitData(botToken string, fields map[string]string) string {
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, k := range keys {
lines = append(lines, k+"="+fields[k])
}
secretMAC := hmac.New(sha256.New, []byte("WebAppData"))
secretMAC.Write([]byte(botToken))
secret := secretMAC.Sum(nil)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(strings.Join(lines, "\n")))
hash := hex.EncodeToString(mac.Sum(nil))
v := url.Values{}
for k, val := range fields {
v.Set(k, val)
}
v.Set("hash", hash)
return v.Encode()
}
func TestValidateAcceptsGenuineInitData(t *testing.T) {
const token = "test-bot-token"
fields := map[string]string{
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
"query_id": "abc",
"user": `{"id":42,"first_name":"Ann","username":"ann"}`,
}
u, err := auth.NewHMACValidator(token).Validate(signedInitData(token, fields))
if err != nil {
t.Fatalf("validate genuine: %v", err)
}
if u.ID != "42" || u.Username != "ann" {
t.Fatalf("user = %+v", u)
}
}
func TestValidateRejectsTamperedHash(t *testing.T) {
const token = "test-bot-token"
fields := map[string]string{
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
"user": `{"id":42}`,
}
data := signedInitData(token, fields) + "0" // corrupt the trailing hash
if _, err := auth.NewHMACValidator(token).Validate(data); err == nil {
t.Fatal("expected rejection of tampered init data")
}
}
func TestValidateRejectsWrongToken(t *testing.T) {
fields := map[string]string{
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
"user": `{"id":42}`,
}
data := signedInitData("real-token", fields)
if _, err := auth.NewHMACValidator("other-token").Validate(data); err == nil {
t.Fatal("expected rejection under a different bot token")
}
}
func TestValidateRejectsStaleInitData(t *testing.T) {
const token = "test-bot-token"
fields := map[string]string{
"auth_date": strconv.FormatInt(time.Now().Add(-48*time.Hour).Unix(), 10),
"user": `{"id":42}`,
}
if _, err := auth.NewHMACValidator(token).Validate(signedInitData(token, fields)); err == nil {
t.Fatal("expected rejection of stale init data")
}
}
+191
View File
@@ -0,0 +1,191 @@
package backendclient
import (
"context"
"net/http"
"net/url"
)
// The structs below mirror the backend's JSON DTOs (backend/internal/server
// /dto.go). The transcode layer maps them to and from the FlatBuffers edge
// payloads.
// SessionResp is the credential minted by an auth operation.
type SessionResp struct {
Token string `json:"token"`
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
DisplayName string `json:"display_name"`
}
// ProfileResp is an account's own profile.
type ProfileResp struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
HintBalance int `json:"hint_balance"`
BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"`
IsGuest bool `json:"is_guest"`
}
// TileJSON is one placed tile, used in both play requests and move responses.
type TileJSON struct {
Row int `json:"row"`
Col int `json:"col"`
Letter string `json:"letter"`
Blank bool `json:"blank"`
}
// MoveRecordResp is a decoded move.
type MoveRecordResp struct {
Player int `json:"player"`
Action string `json:"action"`
Dir string `json:"dir"`
MainRow int `json:"main_row"`
MainCol int `json:"main_col"`
Tiles []TileJSON `json:"tiles"`
Words []string `json:"words"`
Count int `json:"count"`
Score int `json:"score"`
Total int `json:"total"`
}
// SeatResp is one seat's public standing.
type SeatResp struct {
Seat int `json:"seat"`
AccountID string `json:"account_id"`
Score int `json:"score"`
HintsUsed int `json:"hints_used"`
IsWinner bool `json:"is_winner"`
}
// GameResp is the shared game summary.
type GameResp struct {
ID string `json:"id"`
Variant string `json:"variant"`
DictVersion string `json:"dict_version"`
Status string `json:"status"`
Players int `json:"players"`
ToMove int `json:"to_move"`
TurnTimeoutSecs int `json:"turn_timeout_secs"`
MoveCount int `json:"move_count"`
EndReason string `json:"end_reason"`
Seats []SeatResp `json:"seats"`
}
// MoveResultResp is the outcome of a committed move.
type MoveResultResp struct {
Move MoveRecordResp `json:"move"`
Game GameResp `json:"game"`
}
// StateResp is a player's view of a game.
type StateResp struct {
Game GameResp `json:"game"`
Seat int `json:"seat"`
Rack []string `json:"rack"`
BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"`
}
// MatchResp reports an auto-match outcome.
type MatchResp struct {
Matched bool `json:"matched"`
Game *GameResp `json:"game,omitempty"`
}
// ChatResp is a stored chat message.
type ChatResp struct {
ID string `json:"id"`
GameID string `json:"game_id"`
SenderID string `json:"sender_id"`
Kind string `json:"kind"`
Body string `json:"body"`
CreatedAtUnix int64 `json:"created_at_unix"`
}
// TelegramAuth provisions/finds the Telegram account and mints a session.
func (c *Client) TelegramAuth(ctx context.Context, externalID string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
map[string]string{"external_id": externalID}, &out)
return out, err
}
// GuestAuth provisions a guest account and mints a session.
func (c *Client) GuestAuth(ctx context.Context) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "", struct{}{}, &out)
return out, err
}
// EmailRequest asks the backend to mail a login code.
func (c *Client) EmailRequest(ctx context.Context, email string) error {
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
map[string]string{"email": email}, nil)
}
// EmailLogin verifies a login code and mints a session.
func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/login", "", "",
map[string]string{"email": email, "code": code}, &out)
return out, err
}
// ResolveSession maps a token to its account id (gateway session-cache miss).
func (c *Client) ResolveSession(ctx context.Context, token string) (string, error) {
var out struct {
UserID string `json:"user_id"`
}
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
map[string]string{"token": token}, &out)
return out.UserID, err
}
// Profile returns the authenticated account's profile.
func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error) {
var out ProfileResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/profile", userID, "", nil, &out)
return out, err
}
// SubmitPlay commits a placement on the player's turn.
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []TileJSON) (MoveResultResp, error) {
var out MoveResultResp
body := map[string]any{"dir": dir, "tiles": tiles}
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/play", userID, "", body, &out)
return out, err
}
// GameState returns the player's view of a game.
func (c *Client) GameState(ctx context.Context, userID, gameID string) (StateResp, error) {
var out StateResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/games/"+url.PathEscape(gameID)+"/state", userID, "", nil, &out)
return out, err
}
// Enqueue joins the auto-match pool for a variant.
func (c *Client) Enqueue(ctx context.Context, userID, variant string) (MatchResp, error) {
var out MatchResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/lobby/enqueue", userID, "",
map[string]string{"variant": variant}, &out)
return out, err
}
// Poll reports whether the caller has been paired since queueing.
func (c *Client) Poll(ctx context.Context, userID string) (MatchResp, error) {
var out MatchResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/lobby/poll", userID, "", nil, &out)
return out, err
}
// ChatPost stores a chat message, forwarding the client IP for moderation.
func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP string) (ChatResp, error) {
var out ChatResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/chat", userID, clientIP,
map[string]string{"body": body}, &out)
return out, err
}
+122
View File
@@ -0,0 +1,122 @@
// Package backendclient is the gateway's typed client for the backend: REST/JSON
// for synchronous operations (injecting X-User-ID) and a gRPC subscription for
// the live push stream. The response structs mirror the backend's JSON DTOs; the
// transcode layer turns them into FlatBuffers for the client.
package backendclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pushv1 "scrabble/pkg/proto/push/v1"
)
// Client calls the backend's REST API and opens its push gRPC stream.
type Client struct {
baseURL string
http *http.Client
conn *grpc.ClientConn
push pushv1.PushClient
}
// New dials the backend push gRPC endpoint and prepares the REST client. The
// backend lives on a trusted network segment, so the gRPC connection uses
// insecure (plaintext) transport credentials (ARCHITECTURE.md §12).
func New(httpURL, grpcAddr string, timeout time.Duration) (*Client, error) {
conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, fmt.Errorf("backendclient: dial push %s: %w", grpcAddr, err)
}
return &Client{
baseURL: strings.TrimRight(httpURL, "/"),
http: &http.Client{Timeout: timeout},
conn: conn,
push: pushv1.NewPushClient(conn),
}, nil
}
// Close releases the gRPC connection.
func (c *Client) Close() error { return c.conn.Close() }
// APIError carries a backend error response so the transcode layer can surface a
// stable result code to the client.
type APIError struct {
Status int
Code string
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("backend %d (%s): %s", e.Status, e.Code, e.Message)
}
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation). A non-2xx
// response is returned as an *APIError carrying the backend error code.
func (c *Client) do(ctx context.Context, method, path, userID, clientIP string, body, out any) error {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("backendclient: marshal request: %w", err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return fmt.Errorf("backendclient: new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if userID != "" {
req.Header.Set("X-User-ID", userID)
}
if clientIP != "" {
req.Header.Set("X-Forwarded-For", clientIP)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("backendclient: %s %s: %w", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("backendclient: read response: %w", err)
}
if resp.StatusCode >= http.StatusMultipleChoices {
return parseAPIError(resp.StatusCode, data)
}
if out != nil {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("backendclient: decode response: %w", err)
}
}
return nil
}
// parseAPIError extracts the backend's {error:{code,message}} envelope.
func parseAPIError(status int, data []byte) *APIError {
var env struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(data, &env); err == nil && env.Error.Code != "" {
return &APIError{Status: status, Code: env.Error.Code, Message: env.Error.Message}
}
return &APIError{Status: status, Code: "backend_error", Message: strings.TrimSpace(string(data))}
}
// SubscribePush opens the backend live-event stream.
func (c *Client) SubscribePush(ctx context.Context, gatewayID string) (grpc.ServerStreamingClient[pushv1.Event], error) {
return c.push.Subscribe(ctx, &pushv1.SubscribeRequest{GatewayId: gatewayID})
}
+175
View File
@@ -0,0 +1,175 @@
// Package config loads and validates the gateway's runtime configuration from
// the process environment. Every variable is prefixed GATEWAY_.
package config
import (
"fmt"
"os"
"strconv"
"time"
)
// Config holds the gateway's runtime configuration.
type Config struct {
// HTTPAddr is the public Connect/h2c listener address (host:port).
HTTPAddr string
// AdminAddr is the admin reverse-proxy listener address. Admin is enabled only
// when AdminUser and AdminPassword are also set.
AdminAddr string
// LogLevel is the zap log level: "debug", "info", "warn" or "error".
LogLevel string
// BackendHTTPURL is the base URL of the backend REST API (gateway -> backend).
BackendHTTPURL string
// BackendGRPCAddr is the backend push gRPC address the gateway subscribes to.
BackendGRPCAddr string
// BackendTimeout bounds a single backend REST call.
BackendTimeout time.Duration
// AdminUser and AdminPassword are the Basic-Auth credentials the gateway
// checks before proxying admin traffic to the backend. Empty disables admin.
AdminUser string
AdminPassword string
// TelegramBotToken is the secret used to validate Telegram initData HMACs.
// Empty disables the telegram auth path.
TelegramBotToken string
// SessionTTL bounds how long a resolved session stays cached; SessionCacheMax
// caps the number of cached sessions.
SessionTTL time.Duration
SessionCacheMax int
// PushHeartbeatInterval is the idle keep-alive cadence on a client live stream.
PushHeartbeatInterval time.Duration
// RateLimit configures the in-memory anti-abuse limiter.
RateLimit RateLimitConfig
}
// RateLimitConfig holds the token-bucket limits per class. Public and admin are
// keyed per client IP; the authenticated class is keyed per user id; the email
// sub-limit guards the costly email-code path per IP.
type RateLimitConfig struct {
PublicPerMinute int
PublicBurst int
UserPerMinute int
UserBurst int
AdminPerMinute int
AdminBurst int
EmailPer10Min int
EmailBurst int
}
// Defaults applied when the corresponding environment variable is unset.
const (
defaultHTTPAddr = ":8081"
defaultAdminAddr = ":8082"
defaultLogLevel = "info"
defaultBackendHTTPURL = "http://localhost:8080"
defaultBackendGRPCAddr = "localhost:9090"
defaultBackendTimeout = 5 * time.Second
defaultSessionTTL = 10 * time.Minute
defaultSessionCacheMax = 50000
defaultPushHeartbeatInterval = 15 * time.Second
)
// DefaultRateLimit returns the built-in anti-abuse limits.
func DefaultRateLimit() RateLimitConfig {
return RateLimitConfig{
PublicPerMinute: 30, PublicBurst: 10,
UserPerMinute: 120, UserBurst: 40,
AdminPerMinute: 60, AdminBurst: 20,
EmailPer10Min: 5, EmailBurst: 2,
}
}
// Load reads the configuration from the environment, applies defaults, and
// validates the result.
func Load() (Config, error) {
var err error
c := Config{
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
AdminAddr: envOr("GATEWAY_ADMIN_ADDR", defaultAdminAddr),
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
TelegramBotToken: os.Getenv("GATEWAY_TELEGRAM_BOT_TOKEN"),
SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(),
}
if c.BackendTimeout, err = envDuration("GATEWAY_BACKEND_TIMEOUT", defaultBackendTimeout); err != nil {
return Config{}, err
}
if c.SessionTTL, err = envDuration("GATEWAY_SESSION_TTL", defaultSessionTTL); err != nil {
return Config{}, err
}
if c.SessionCacheMax, err = envInt("GATEWAY_SESSION_CACHE_MAX", defaultSessionCacheMax); err != nil {
return Config{}, err
}
if c.PushHeartbeatInterval, err = envDuration("GATEWAY_PUSH_HEARTBEAT_INTERVAL", defaultPushHeartbeatInterval); err != nil {
return Config{}, err
}
if err := c.validate(); err != nil {
return Config{}, err
}
return c, nil
}
// AdminEnabled reports whether the admin proxy should be served (an address and
// both Basic-Auth credentials are configured).
func (c Config) AdminEnabled() bool {
return c.AdminAddr != "" && c.AdminUser != "" && c.AdminPassword != ""
}
// validate reports whether the configuration values are acceptable.
func (c Config) validate() error {
switch c.LogLevel {
case "debug", "info", "warn", "error":
default:
return fmt.Errorf("config: invalid GATEWAY_LOG_LEVEL %q", c.LogLevel)
}
if c.HTTPAddr == "" {
return fmt.Errorf("config: GATEWAY_HTTP_ADDR must not be empty")
}
if c.BackendHTTPURL == "" {
return fmt.Errorf("config: GATEWAY_BACKEND_HTTP_URL must not be empty")
}
if c.BackendGRPCAddr == "" {
return fmt.Errorf("config: GATEWAY_BACKEND_GRPC_ADDR must not be empty")
}
return nil
}
// envOr returns the value of the environment variable named key, or fallback
// when the variable is unset or empty.
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// envInt parses the environment variable named key as an int, returning fallback
// when it is unset and an error when it is set but malformed.
func envInt(key string, fallback int) (int, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return n, nil
}
// envDuration parses the environment variable named key as a Go duration,
// returning fallback when it is unset and an error when it is set but malformed.
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return d, nil
}
+20
View File
@@ -0,0 +1,20 @@
package connectsrv
import (
"errors"
"fmt"
)
// Edge-level error values wrapped in Connect status codes. Domain outcomes are
// not here — they ride back in the ExecuteResponse result_code.
var (
errRateLimited = errors.New("rate limit exceeded")
errInternal = errors.New("internal error")
errMissingToken = errors.New("missing session token")
errInvalidSession = errors.New("invalid or expired session")
)
// errUnknownMessageType reports an unregistered message type.
func errUnknownMessageType(msgType string) error {
return fmt.Errorf("unknown message type %q", msgType)
}
+209
View File
@@ -0,0 +1,209 @@
// Package connectsrv implements the public Connect edge service over h2c. Execute
// rate-limits, authenticates (resolving the Authorization bearer token to a user
// id for non-auth operations), and dispatches to the transcode registry; the
// domain outcome is carried back in the ExecuteResponse result_code. Subscribe
// bridges the gateway push hub to a client server-stream with a keep-alive
// heartbeat.
package connectsrv
import (
"context"
"net"
"net/http"
"strings"
"time"
"connectrpc.com/connect"
"go.uber.org/zap"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/push"
"scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode"
edgev1 "scrabble/gateway/proto/edge/v1"
"scrabble/gateway/proto/edge/v1/edgev1connect"
)
// heartbeatKind is the live-stream keep-alive event kind.
const heartbeatKind = "heartbeat"
// Server implements edgev1connect.GatewayHandler.
type Server struct {
registry *transcode.Registry
sessions *session.Cache
limiter *ratelimit.Limiter
hub *push.Hub
heartbeat time.Duration
log *zap.Logger
publicPolicy ratelimit.Policy
userPolicy ratelimit.Policy
emailPolicy ratelimit.Policy
}
// Deps carries the Server's dependencies.
type Deps struct {
Registry *transcode.Registry
Sessions *session.Cache
Limiter *ratelimit.Limiter
Hub *push.Hub
RateLimit config.RateLimitConfig
Heartbeat time.Duration
Logger *zap.Logger
}
// NewServer constructs the edge service.
func NewServer(d Deps) *Server {
log := d.Logger
if log == nil {
log = zap.NewNop()
}
return &Server{
registry: d.Registry,
sessions: d.Sessions,
limiter: d.Limiter,
hub: d.Hub,
heartbeat: d.Heartbeat,
log: log,
publicPolicy: ratelimit.PerMinute(d.RateLimit.PublicPerMinute, d.RateLimit.PublicBurst),
userPolicy: ratelimit.PerMinute(d.RateLimit.UserPerMinute, d.RateLimit.UserBurst),
emailPolicy: ratelimit.Per(d.RateLimit.EmailPer10Min, 10*time.Minute, d.RateLimit.EmailBurst),
}
}
// HTTPHandler returns the h2c-wrapped Connect handler ready to serve.
func (s *Server) HTTPHandler() http.Handler {
mux := http.NewServeMux()
path, h := edgev1connect.NewGatewayHandler(s)
mux.Handle(path, h)
return h2c.NewHandler(mux, &http2.Server{})
}
// Execute runs one unary operation. Domain failures are returned in the envelope
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
// session, unknown type, internal) become Connect errors.
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (*connect.Response[edgev1.ExecuteResponse], error) {
msgType := req.Msg.GetMessageType()
op, ok := s.registry.Lookup(msgType)
if !ok {
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
}
clientIP := peerIP(req.Peer().Addr, req.Header())
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
if op.Auth {
uid, err := s.resolve(ctx, req.Header())
if err != nil {
return nil, err
}
if !s.limiter.Allow("user:"+uid, s.userPolicy) {
return nil, connect.NewError(connect.CodeResourceExhausted, errRateLimited)
}
tr.UserID = uid
} else {
if !s.limiter.Allow("ip:"+clientIP, s.publicPolicy) {
return nil, connect.NewError(connect.CodeResourceExhausted, errRateLimited)
}
if op.Email && !s.limiter.Allow("email:"+clientIP, s.emailPolicy) {
return nil, connect.NewError(connect.CodeResourceExhausted, errRateLimited)
}
}
payload, err := op.Handler(ctx, tr)
if err != nil {
if code, domain := transcode.DomainCode(err); domain {
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: code,
}), nil
}
s.log.Error("execute failed", zap.String("message_type", msgType), zap.Error(err))
return nil, connect.NewError(connect.CodeInternal, errInternal)
}
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: "ok",
Payload: payload,
}), nil
}
// Subscribe streams the authenticated user's live events with a keep-alive
// heartbeat until the client disconnects.
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
uid, err := s.resolve(ctx, req.Header())
if err != nil {
return err
}
if !s.limiter.Allow("user:"+uid, s.userPolicy) {
return connect.NewError(connect.CodeResourceExhausted, errRateLimited)
}
events, cancel := s.hub.Subscribe(uid)
defer cancel()
ticker := time.NewTicker(s.heartbeat)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := stream.Send(&edgev1.Event{Kind: heartbeatKind}); err != nil {
return err
}
case e, ok := <-events:
if !ok {
return nil
}
if err := stream.Send(&edgev1.Event{Kind: e.Kind, Payload: e.Payload, EventId: e.EventID}); err != nil {
return err
}
}
}
}
// resolve extracts and resolves the Authorization bearer token to an account id,
// returning a Connect Unauthenticated error when it is missing or unknown.
func (s *Server) resolve(ctx context.Context, h http.Header) (string, error) {
token := bearerToken(h.Get("Authorization"))
if token == "" {
return "", connect.NewError(connect.CodeUnauthenticated, errMissingToken)
}
uid, err := s.sessions.Resolve(ctx, token)
if err != nil {
return "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
}
return uid, nil
}
// bearerToken extracts the token from an "Authorization: Bearer <token>" header,
// tolerating a bare token for convenience.
func bearerToken(header string) string {
header = strings.TrimSpace(header)
if header == "" {
return ""
}
if rest, ok := strings.CutPrefix(header, "Bearer "); ok {
return strings.TrimSpace(rest)
}
return header
}
// peerIP prefers the X-Forwarded-For client hop, falling back to the connection
// peer address (host part).
func peerIP(peerAddr string, h http.Header) string {
if xff := h.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if host, _, err := net.SplitHostPort(peerAddr); err == nil {
return host
}
return peerAddr
}
@@ -0,0 +1,96 @@
package connectsrv_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"connectrpc.com/connect"
"scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/connectsrv"
"scrabble/gateway/internal/push"
"scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode"
edgev1 "scrabble/gateway/proto/edge/v1"
"scrabble/gateway/proto/edge/v1/edgev1connect"
fb "scrabble/pkg/fbs/scrabblefb"
)
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
// client plus a cleanup func.
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
if err != nil {
t.Fatalf("backendclient: %v", err)
}
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Hub: push.NewHub(0),
RateLimit: config.DefaultRateLimit(),
Heartbeat: 15 * time.Second,
})
edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
return client, func() {
edgeSrv.Close()
_ = backend.Close()
backendSrv.Close()
}
}
func TestExecuteGuestAuthOK(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
})
defer cleanup()
resp, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{
MessageType: transcode.MsgAuthGuest,
RequestId: "req-1",
}))
if err != nil {
t.Fatalf("execute: %v", err)
}
if resp.Msg.GetResultCode() != "ok" || resp.Msg.GetRequestId() != "req-1" {
t.Fatalf("result = %q req_id = %q", resp.Msg.GetResultCode(), resp.Msg.GetRequestId())
}
sess := fb.GetRootAsSession(resp.Msg.GetPayload(), 0)
if string(sess.Token()) != "tok" || !sess.IsGuest() {
t.Fatalf("session decoded wrong: %q guest=%v", sess.Token(), sess.IsGuest())
}
}
func TestExecuteAuthedRequiresSession(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called without a session")
})
defer cleanup()
_, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{
MessageType: transcode.MsgProfileGet,
}))
if connect.CodeOf(err) != connect.CodeUnauthenticated {
t.Fatalf("code = %v, want Unauthenticated", connect.CodeOf(err))
}
}
func TestExecuteUnknownMessageType(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
_, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{
MessageType: "does.not.exist",
}))
if connect.CodeOf(err) != connect.CodeNotFound {
t.Fatalf("code = %v, want NotFound", connect.CodeOf(err))
}
}
+88
View File
@@ -0,0 +1,88 @@
// Package push is the gateway's live-event fan-out. The gateway holds one
// backend gRPC subscription that feeds Publish; each connected client opens a
// Subscribe stream and receives only the events addressed to its user id. A slow
// client never blocks the backend feed — its bounded queue drops on overflow.
package push
import "sync"
// Event is one live event addressed to a user. Payload is the FlatBuffers body
// the gateway forwards verbatim to the client.
type Event struct {
UserID string
Kind string
Payload []byte
EventID string
}
// defaultBuffer is the per-client queue depth used when NewHub is given a
// non-positive size.
const defaultBuffer = 64
// Hub fans backend events out to per-user client subscriptions.
type Hub struct {
bufSize int
mu sync.Mutex
nextID int
subs map[int]*subscription
}
type subscription struct {
userID string
ch chan Event
}
// NewHub constructs a Hub whose per-client queue holds bufSize events.
func NewHub(bufSize int) *Hub {
if bufSize <= 0 {
bufSize = defaultBuffer
}
return &Hub{bufSize: bufSize, subs: make(map[int]*subscription)}
}
// Publish delivers e to every subscription for e.UserID, dropping it for any
// whose queue is full.
func (h *Hub) Publish(e Event) {
h.mu.Lock()
defer h.mu.Unlock()
for _, s := range h.subs {
if s.userID != e.UserID {
continue
}
select {
case s.ch <- e:
default:
}
}
}
// Subscribe registers a client stream for userID and returns its event channel
// and an unsubscribe func that closes the channel.
func (h *Hub) Subscribe(userID string) (<-chan Event, func()) {
h.mu.Lock()
defer h.mu.Unlock()
id := h.nextID
h.nextID++
s := &subscription{userID: userID, ch: make(chan Event, h.bufSize)}
h.subs[id] = s
return s.ch, func() { h.unsubscribe(id) }
}
// unsubscribe removes and closes a subscription. It holds the same lock as
// Publish, so it never closes a channel mid-send.
func (h *Hub) unsubscribe(id int) {
h.mu.Lock()
defer h.mu.Unlock()
if s, ok := h.subs[id]; ok {
delete(h.subs, id)
close(s.ch)
}
}
// SubscriberCount returns the number of active subscriptions (for tests/metrics).
func (h *Hub) SubscriberCount() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.subs)
}
+56
View File
@@ -0,0 +1,56 @@
package push_test
import (
"testing"
"scrabble/gateway/internal/push"
)
func TestHubRoutesByUser(t *testing.T) {
h := push.NewHub(4)
chA, cancelA := h.Subscribe("user-a")
defer cancelA()
chB, cancelB := h.Subscribe("user-b")
defer cancelB()
h.Publish(push.Event{UserID: "user-a", Kind: "your_turn"})
select {
case e := <-chA:
if e.Kind != "your_turn" {
t.Fatalf("user-a received %q", e.Kind)
}
default:
t.Fatal("user-a should have received the event")
}
select {
case <-chB:
t.Fatal("user-b must not receive user-a's event")
default:
}
}
func TestHubDropsOnOverflow(t *testing.T) {
h := push.NewHub(1)
ch, cancel := h.Subscribe("u")
defer cancel()
for i := 0; i < 5; i++ {
h.Publish(push.Event{UserID: "u", Kind: "chat_message"})
}
if got := len(ch); got != 1 {
t.Fatalf("buffered %d events, want 1 (overflow dropped)", got)
}
}
func TestHubUnsubscribeClosesChannel(t *testing.T) {
h := push.NewHub(2)
ch, cancel := h.Subscribe("u")
cancel()
if _, ok := <-ch; ok {
t.Fatal("channel should be closed after unsubscribe")
}
if h.SubscriberCount() != 0 {
t.Fatalf("subscriber count = %d, want 0", h.SubscriberCount())
}
h.Publish(push.Event{UserID: "u"}) // must not panic
}
+87
View File
@@ -0,0 +1,87 @@
// Package ratelimit is the gateway's in-memory anti-abuse limiter: a token
// bucket per key (golang.org/x/time/rate). The connect edge keys the public
// class per client IP, the authenticated class per user id, and a stricter
// sub-limit guards the email-code path; the admin proxy keys per IP. Buckets are
// swept lazily so an idle key does not leak memory.
package ratelimit
import (
"sync"
"time"
"golang.org/x/time/rate"
)
// Policy is a token-bucket rate and burst.
type Policy struct {
Limit rate.Limit
Burst int
}
// PerMinute builds a Policy allowing perMinute events per minute with the given
// burst.
func PerMinute(perMinute, burst int) Policy {
return Policy{Limit: rate.Limit(float64(perMinute) / 60.0), Burst: burst}
}
// Per builds a Policy allowing events per window with the given burst.
func Per(events int, window time.Duration, burst int) Policy {
return Policy{Limit: rate.Limit(float64(events) / window.Seconds()), Burst: burst}
}
// staleAfter is how long an unused bucket is retained before the lazy sweep
// discards it; sweepInterval bounds how often the sweep runs.
const (
staleAfter = 10 * time.Minute
sweepInterval = time.Minute
)
// Limiter holds the per-key token buckets.
type Limiter struct {
now func() time.Time
mu sync.Mutex
buckets map[string]*bucket
lastSweep time.Time
}
type bucket struct {
lim *rate.Limiter
seen time.Time
}
// New constructs an empty Limiter.
func New() *Limiter {
now := func() time.Time { return time.Now() }
return &Limiter{now: now, buckets: make(map[string]*bucket), lastSweep: now()}
}
// Allow reports whether one event under key is permitted by policy, consuming a
// token when it is.
func (l *Limiter) Allow(key string, p Policy) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
l.sweepLocked(now)
b, ok := l.buckets[key]
if !ok {
b = &bucket{lim: rate.NewLimiter(p.Limit, p.Burst)}
l.buckets[key] = b
}
b.seen = now
return b.lim.Allow()
}
// sweepLocked discards buckets unused for staleAfter, at most once per
// sweepInterval. The caller holds l.mu.
func (l *Limiter) sweepLocked(now time.Time) {
if now.Sub(l.lastSweep) < sweepInterval {
return
}
l.lastSweep = now
for k, b := range l.buckets {
if now.Sub(b.seen) > staleAfter {
delete(l.buckets, k)
}
}
}
@@ -0,0 +1,46 @@
package ratelimit_test
import (
"testing"
"time"
"scrabble/gateway/internal/ratelimit"
)
func TestAllowEnforcesBurst(t *testing.T) {
l := ratelimit.New()
p := ratelimit.PerMinute(60, 3) // 1/s, burst 3
allowed := 0
for i := 0; i < 5; i++ {
if l.Allow("ip:1.2.3.4", p) {
allowed++
}
}
if allowed != 3 {
t.Fatalf("allowed %d of 5, want 3 (burst)", allowed)
}
}
func TestAllowIsolatesKeys(t *testing.T) {
l := ratelimit.New()
p := ratelimit.PerMinute(60, 1)
if !l.Allow("user:a", p) {
t.Fatal("first key should be allowed")
}
if !l.Allow("user:b", p) {
t.Fatal("a different key must have its own bucket")
}
if l.Allow("user:a", p) {
t.Fatal("the first key's bucket should now be empty")
}
}
func TestPerWindow(t *testing.T) {
// 5 events per 10 minutes, burst 2: the third immediate call is denied.
p := ratelimit.Per(5, 10*time.Minute, 2)
l := ratelimit.New()
got := []bool{l.Allow("email:x", p), l.Allow("email:x", p), l.Allow("email:x", p)}
if !got[0] || !got[1] || got[2] {
t.Fatalf("per-window burst = %v, want [true true false]", got)
}
}
+108
View File
@@ -0,0 +1,108 @@
// Package session is the gateway's in-memory session cache. It maps an opaque
// bearer token to the backend account id, falling back to the backend's resolve
// endpoint on a miss and caching the result for a bounded TTL. The backend
// remains the source of truth (sessions are revoke-only there); the cache only
// shortcuts the hot path.
package session
import (
"context"
"sync"
"time"
)
// Resolver resolves a token to an account id at the backend (the cache miss
// path). backendclient.Client satisfies it.
type Resolver interface {
ResolveSession(ctx context.Context, token string) (string, error)
}
// Cache resolves session tokens to account ids, caching hits for ttl.
type Cache struct {
backend Resolver
ttl time.Duration
max int
now func() time.Time
mu sync.Mutex
entries map[string]entry
}
type entry struct {
userID string
expires time.Time
}
// NewCache constructs a Cache over backend with the given TTL and maximum size.
func NewCache(backend Resolver, ttl time.Duration, max int) *Cache {
if max <= 0 {
max = 1
}
return &Cache{
backend: backend,
ttl: ttl,
max: max,
now: func() time.Time { return time.Now() },
entries: make(map[string]entry),
}
}
// Resolve returns the account id for token, consulting the cache first and the
// backend on a miss (caching the result). An empty token is rejected by the
// backend like any unknown token.
func (c *Cache) Resolve(ctx context.Context, token string) (string, error) {
if uid, ok := c.lookup(token); ok {
return uid, nil
}
uid, err := c.backend.ResolveSession(ctx, token)
if err != nil {
return "", err
}
c.store(token, uid)
return uid, nil
}
// Invalidate drops a token from the cache (e.g. after a revoke).
func (c *Cache) Invalidate(token string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, token)
}
// lookup returns a live cached account id for token.
func (c *Cache) lookup(token string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[token]
if !ok || !c.now().Before(e.expires) {
return "", false
}
return e.userID, true
}
// store caches token -> userID, sweeping expired entries and bounding the size.
func (c *Cache) store(token, userID string) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.entries) >= c.max {
c.evictLocked()
}
c.entries[token] = entry{userID: userID, expires: c.now().Add(c.ttl)}
}
// evictLocked removes expired entries and, if still at capacity, drops arbitrary
// entries until below the limit. The caller holds c.mu.
func (c *Cache) evictLocked() {
now := c.now()
for k, e := range c.entries {
if !now.Before(e.expires) {
delete(c.entries, k)
}
}
for k := range c.entries {
if len(c.entries) < c.max {
break
}
delete(c.entries, k)
}
}
+74
View File
@@ -0,0 +1,74 @@
package session
import (
"context"
"errors"
"testing"
"time"
)
type fakeResolver struct {
uid string
err error
calls int
}
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, error) {
f.calls++
if f.err != nil {
return "", f.err
}
return f.uid, nil
}
func TestResolveCachesBackendHit(t *testing.T) {
r := &fakeResolver{uid: "user-1"}
c := NewCache(r, time.Minute, 10)
for i := 0; i < 3; i++ {
uid, err := c.Resolve(context.Background(), "tok")
if err != nil || uid != "user-1" {
t.Fatalf("resolve #%d = (%q, %v)", i, uid, err)
}
}
if r.calls != 1 {
t.Fatalf("backend calls = %d, want 1 (cached)", r.calls)
}
}
func TestResolvePropagatesBackendError(t *testing.T) {
r := &fakeResolver{err: errors.New("nope")}
c := NewCache(r, time.Minute, 10)
if _, err := c.Resolve(context.Background(), "tok"); err == nil {
t.Fatal("expected backend error to propagate")
}
}
func TestResolveReResolvesAfterTTL(t *testing.T) {
r := &fakeResolver{uid: "user-1"}
c := NewCache(r, time.Minute, 10)
base := time.Now()
c.now = func() time.Time { return base }
if _, err := c.Resolve(context.Background(), "tok"); err != nil {
t.Fatal(err)
}
c.now = func() time.Time { return base.Add(2 * time.Minute) } // past TTL
if _, err := c.Resolve(context.Background(), "tok"); err != nil {
t.Fatal(err)
}
if r.calls != 2 {
t.Fatalf("backend calls = %d, want 2 (re-resolve after expiry)", r.calls)
}
}
func TestInvalidateForcesReResolve(t *testing.T) {
r := &fakeResolver{uid: "user-1"}
c := NewCache(r, time.Minute, 10)
_, _ = c.Resolve(context.Background(), "tok")
c.Invalidate("tok")
_, _ = c.Resolve(context.Background(), "tok")
if r.calls != 2 {
t.Fatalf("backend calls = %d, want 2 after invalidate", r.calls)
}
}
+209
View File
@@ -0,0 +1,209 @@
package transcode
import (
flatbuffers "github.com/google/flatbuffers/go"
"scrabble/gateway/internal/backendclient"
fb "scrabble/pkg/fbs/scrabblefb"
)
// The encoders build the FlatBuffers response payloads from the backend's typed
// responses. FlatBuffers is built bottom-up: every string and child vector is
// created before the table that references it, and no two tables/vectors are
// under construction at once.
// encodeSession builds a Session payload.
func encodeSession(s backendclient.SessionResp) []byte {
b := flatbuffers.NewBuilder(128)
token := b.CreateString(s.Token)
uid := b.CreateString(s.UserID)
name := b.CreateString(s.DisplayName)
fb.SessionStart(b)
fb.SessionAddToken(b, token)
fb.SessionAddUserId(b, uid)
fb.SessionAddIsGuest(b, s.IsGuest)
fb.SessionAddDisplayName(b, name)
b.Finish(fb.SessionEnd(b))
return b.FinishedBytes()
}
// encodeAck builds an Ack payload.
func encodeAck(ok bool) []byte {
b := flatbuffers.NewBuilder(16)
fb.AckStart(b)
fb.AckAddOk(b, ok)
b.Finish(fb.AckEnd(b))
return b.FinishedBytes()
}
// encodeProfile builds a Profile payload.
func encodeProfile(p backendclient.ProfileResp) []byte {
b := flatbuffers.NewBuilder(192)
uid := b.CreateString(p.UserID)
name := b.CreateString(p.DisplayName)
lang := b.CreateString(p.PreferredLanguage)
tz := b.CreateString(p.TimeZone)
fb.ProfileStart(b)
fb.ProfileAddUserId(b, uid)
fb.ProfileAddDisplayName(b, name)
fb.ProfileAddPreferredLanguage(b, lang)
fb.ProfileAddTimeZone(b, tz)
fb.ProfileAddHintBalance(b, int32(p.HintBalance))
fb.ProfileAddBlockChat(b, p.BlockChat)
fb.ProfileAddBlockFriendRequests(b, p.BlockFriendRequests)
fb.ProfileAddIsGuest(b, p.IsGuest)
b.Finish(fb.ProfileEnd(b))
return b.FinishedBytes()
}
// encodeMoveResult builds a MoveResult payload.
func encodeMoveResult(r backendclient.MoveResultResp) []byte {
b := flatbuffers.NewBuilder(512)
move := buildMoveRecord(b, r.Move)
game := buildGameView(b, r.Game)
fb.MoveResultStart(b)
fb.MoveResultAddMove(b, move)
fb.MoveResultAddGame(b, game)
b.Finish(fb.MoveResultEnd(b))
return b.FinishedBytes()
}
// encodeState builds a StateView payload.
func encodeState(s backendclient.StateResp) []byte {
b := flatbuffers.NewBuilder(512)
game := buildGameView(b, s.Game)
rack := buildStringVector(b, s.Rack, fb.StateViewStartRackVector)
fb.StateViewStart(b)
fb.StateViewAddGame(b, game)
fb.StateViewAddSeat(b, int32(s.Seat))
fb.StateViewAddRack(b, rack)
fb.StateViewAddBagLen(b, int32(s.BagLen))
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
b.Finish(fb.StateViewEnd(b))
return b.FinishedBytes()
}
// encodeMatch builds a MatchResult payload.
func encodeMatch(m backendclient.MatchResp) []byte {
b := flatbuffers.NewBuilder(512)
matched := m.Matched && m.Game != nil
var game flatbuffers.UOffsetT
if matched {
game = buildGameView(b, *m.Game)
}
fb.MatchResultStart(b)
fb.MatchResultAddMatched(b, matched)
if matched {
fb.MatchResultAddGame(b, game)
}
b.Finish(fb.MatchResultEnd(b))
return b.FinishedBytes()
}
// encodeChat builds a ChatMessage payload.
func encodeChat(c backendclient.ChatResp) []byte {
b := flatbuffers.NewBuilder(192)
id := b.CreateString(c.ID)
gid := b.CreateString(c.GameID)
sid := b.CreateString(c.SenderID)
kind := b.CreateString(c.Kind)
body := b.CreateString(c.Body)
fb.ChatMessageStart(b)
fb.ChatMessageAddId(b, id)
fb.ChatMessageAddGameId(b, gid)
fb.ChatMessageAddSenderId(b, sid)
fb.ChatMessageAddKind(b, kind)
fb.ChatMessageAddBody(b, body)
fb.ChatMessageAddCreatedAtUnix(b, c.CreatedAtUnix)
b.Finish(fb.ChatMessageEnd(b))
return b.FinishedBytes()
}
// buildGameView builds a GameView table and returns its offset.
func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers.UOffsetT {
seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats))
for i, s := range g.Seats {
aid := b.CreateString(s.AccountID)
fb.SeatViewStart(b)
fb.SeatViewAddSeat(b, int32(s.Seat))
fb.SeatViewAddAccountId(b, aid)
fb.SeatViewAddScore(b, int32(s.Score))
fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed))
fb.SeatViewAddIsWinner(b, s.IsWinner)
seatOffs[i] = fb.SeatViewEnd(b)
}
fb.GameViewStartSeatsVector(b, len(seatOffs))
for i := len(seatOffs) - 1; i >= 0; i-- {
b.PrependUOffsetT(seatOffs[i])
}
seats := b.EndVector(len(seatOffs))
id := b.CreateString(g.ID)
variant := b.CreateString(g.Variant)
dictVer := b.CreateString(g.DictVersion)
status := b.CreateString(g.Status)
endReason := b.CreateString(g.EndReason)
fb.GameViewStart(b)
fb.GameViewAddId(b, id)
fb.GameViewAddVariant(b, variant)
fb.GameViewAddDictVersion(b, dictVer)
fb.GameViewAddStatus(b, status)
fb.GameViewAddPlayers(b, int32(g.Players))
fb.GameViewAddToMove(b, int32(g.ToMove))
fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs))
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
fb.GameViewAddEndReason(b, endReason)
fb.GameViewAddSeats(b, seats)
return fb.GameViewEnd(b)
}
// buildMoveRecord builds a MoveRecord table and returns its offset.
func buildMoveRecord(b *flatbuffers.Builder, m backendclient.MoveRecordResp) flatbuffers.UOffsetT {
tileOffs := make([]flatbuffers.UOffsetT, len(m.Tiles))
for i, t := range m.Tiles {
letter := b.CreateString(t.Letter)
fb.TileRecordStart(b)
fb.TileRecordAddRow(b, int32(t.Row))
fb.TileRecordAddCol(b, int32(t.Col))
fb.TileRecordAddLetter(b, letter)
fb.TileRecordAddBlank(b, t.Blank)
tileOffs[i] = fb.TileRecordEnd(b)
}
fb.MoveRecordStartTilesVector(b, len(tileOffs))
for i := len(tileOffs) - 1; i >= 0; i-- {
b.PrependUOffsetT(tileOffs[i])
}
tiles := b.EndVector(len(tileOffs))
words := buildStringVector(b, m.Words, fb.MoveRecordStartWordsVector)
action := b.CreateString(m.Action)
dir := b.CreateString(m.Dir)
fb.MoveRecordStart(b)
fb.MoveRecordAddPlayer(b, int32(m.Player))
fb.MoveRecordAddAction(b, action)
fb.MoveRecordAddDir(b, dir)
fb.MoveRecordAddMainRow(b, int32(m.MainRow))
fb.MoveRecordAddMainCol(b, int32(m.MainCol))
fb.MoveRecordAddTiles(b, tiles)
fb.MoveRecordAddWords(b, words)
fb.MoveRecordAddCount(b, int32(m.Count))
fb.MoveRecordAddScore(b, int32(m.Score))
fb.MoveRecordAddTotal(b, int32(m.Total))
return fb.MoveRecordEnd(b)
}
// buildStringVector builds a vector of strings using the table-specific
// StartXVector function and returns the vector offset.
func buildStringVector(b *flatbuffers.Builder, items []string, start func(*flatbuffers.Builder, int) flatbuffers.UOffsetT) flatbuffers.UOffsetT {
offs := make([]flatbuffers.UOffsetT, len(items))
for i, s := range items {
offs[i] = b.CreateString(s)
}
start(b, len(offs))
for i := len(offs) - 1; i >= 0; i-- {
b.PrependUOffsetT(offs[i])
}
return b.EndVector(len(offs))
}
+221
View File
@@ -0,0 +1,221 @@
// Package transcode is the gateway's FlatBuffers<->REST bridge. Each message type
// maps to a handler that decodes the FlatBuffers request payload, calls the
// backend over REST, and encodes the FlatBuffers response. The registry is the
// authoritative message_type catalog; new operations are added here following the
// same pattern (PLAN.md Stage 6 vertical slice).
package transcode
import (
"context"
"errors"
"scrabble/gateway/internal/auth"
"scrabble/gateway/internal/backendclient"
fb "scrabble/pkg/fbs/scrabblefb"
)
// Message types in the vertical slice.
const (
MsgAuthTelegram = "auth.telegram"
MsgAuthGuest = "auth.guest"
MsgAuthEmailReq = "auth.email.request"
MsgAuthEmailLogin = "auth.email.login"
MsgProfileGet = "profile.get"
MsgGameSubmitPlay = "game.submit_play"
MsgGameState = "game.state"
MsgLobbyEnqueue = "lobby.enqueue"
MsgLobbyPoll = "lobby.poll"
MsgChatPost = "chat.post"
)
// Request is one decoded Execute call.
type Request struct {
Payload []byte
UserID string // resolved account id; empty for auth (unauthenticated) ops
ClientIP string
}
// Handler runs one operation and returns the FlatBuffers response payload.
type Handler func(ctx context.Context, req Request) ([]byte, error)
// Op is a registered message type and its policy flags.
type Op struct {
Handler Handler
// Auth marks an operation that requires a resolved session (X-User-ID).
Auth bool
// Email marks the costly email-code path that gets a stricter rate sub-limit.
Email bool
}
// Registry maps message types to their operations.
type Registry struct {
ops map[string]Op
}
// NewRegistry builds the slice's message-type catalog over the backend client.
// The Telegram auth op is registered only when a validator is supplied (a bot
// token is configured); otherwise auth.telegram is simply unknown.
func NewRegistry(backend *backendclient.Client, tg auth.TelegramValidator) *Registry {
r := &Registry{ops: make(map[string]Op)}
if tg != nil {
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
}
r.ops[MsgAuthGuest] = Op{Handler: authGuestHandler(backend)}
r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true}
r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend), Email: true}
r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true}
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true}
r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(backend), Auth: true}
r.ops[MsgChatPost] = Op{Handler: chatPostHandler(backend), Auth: true}
return r
}
// Lookup returns the operation for messageType, and whether it is registered.
func (r *Registry) Lookup(messageType string) (Op, bool) {
op, ok := r.ops[messageType]
return op, ok
}
// DomainCode maps an error to a stable result code to surface in the Execute
// envelope, reporting false for an unexpected error the caller should treat as a
// transport-level internal failure.
func DomainCode(err error) (string, bool) {
var apiErr *backendclient.APIError
if errors.As(err, &apiErr) {
return apiErr.Code, true
}
if errors.Is(err, auth.ErrInvalidInitData) {
return "invalid_init_data", true
}
return "", false
}
func authTelegramHandler(backend *backendclient.Client, tg auth.TelegramValidator) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0)
user, err := tg.Validate(string(in.InitData()))
if err != nil {
return nil, err
}
sess, err := backend.TelegramAuth(ctx, user.ID)
if err != nil {
return nil, err
}
return encodeSession(sess), nil
}
}
func authGuestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, _ Request) ([]byte, error) {
sess, err := backend.GuestAuth(ctx)
if err != nil {
return nil, err
}
return encodeSession(sess), nil
}
}
func authEmailRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailRequestRequest(req.Payload, 0)
if err := backend.EmailRequest(ctx, string(in.Email())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func authEmailLoginHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailLoginRequest(req.Payload, 0)
sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code()))
if err != nil {
return nil, err
}
return encodeSession(sess), nil
}
}
func profileHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
p, err := backend.Profile(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeProfile(p), nil
}
}
func submitPlayHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsSubmitPlayRequest(req.Payload, 0)
res, err := backend.SubmitPlay(ctx, req.UserID, string(in.GameId()), string(in.Dir()), decodeTiles(in))
if err != nil {
return nil, err
}
return encodeMoveResult(res), nil
}
}
func gameStateHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsStateRequest(req.Payload, 0)
st, err := backend.GameState(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeState(st), nil
}
}
func enqueueHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEnqueueRequest(req.Payload, 0)
m, err := backend.Enqueue(ctx, req.UserID, string(in.Variant()))
if err != nil {
return nil, err
}
return encodeMatch(m), nil
}
}
func pollHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
m, err := backend.Poll(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeMatch(m), nil
}
}
func chatPostHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsChatPostRequest(req.Payload, 0)
c, err := backend.ChatPost(ctx, req.UserID, string(in.GameId()), string(in.Body()), req.ClientIP)
if err != nil {
return nil, err
}
return encodeChat(c), nil
}
}
// decodeTiles reads the placed tiles from a SubmitPlayRequest.
func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.TileJSON {
n := in.TilesLength()
tiles := make([]backendclient.TileJSON, 0, n)
var t fb.TileRecord
for i := 0; i < n; i++ {
if in.Tiles(&t, i) {
tiles = append(tiles, backendclient.TileJSON{
Row: int(t.Row()),
Col: int(t.Col()),
Letter: string(t.Letter()),
Blank: t.Blank(),
})
}
}
return tiles
}
@@ -0,0 +1,141 @@
package transcode_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
flatbuffers "github.com/google/flatbuffers/go"
"scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/transcode"
fb "scrabble/pkg/fbs/scrabblefb"
)
// fakeBackend serves the subset of backend endpoints the slice handlers call.
func fakeBackend(t *testing.T, h http.HandlerFunc) (*backendclient.Client, func()) {
t.Helper()
srv := httptest.NewServer(h)
c, err := backendclient.New(srv.URL, "localhost:9090", 2_000_000_000)
if err != nil {
t.Fatalf("backendclient: %v", err)
}
return c, func() {
_ = c.Close()
srv.Close()
}
}
func TestGuestAuthRoundTrip(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/internal/sessions/guest" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"token":"tok-1","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, ok := reg.Lookup(transcode.MsgAuthGuest)
if !ok {
t.Fatal("auth.guest not registered")
}
payload, err := op.Handler(context.Background(), transcode.Request{})
if err != nil {
t.Fatalf("handler: %v", err)
}
sess := fb.GetRootAsSession(payload, 0)
if string(sess.Token()) != "tok-1" || string(sess.UserId()) != "u-1" || !sess.IsGuest() {
t.Fatalf("session decoded wrong: token=%q user=%q guest=%v", sess.Token(), sess.UserId(), sess.IsGuest())
}
}
func TestGameStateRoundTripForwardsUserID(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-User-ID"); got != "u-7" {
t.Errorf("X-User-ID = %q, want u-7", got)
}
if r.URL.Path != "/api/v1/user/games/g-1/state" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"english","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":["A","B"],"bag_len":80,"hints_remaining":1}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgGameState)
b := flatbuffers.NewBuilder(32)
gid := b.CreateString("g-1")
fb.StateRequestStart(b)
fb.StateRequestAddGameId(b, gid)
b.Finish(fb.StateRequestEnd(b))
payload, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-7"})
if err != nil {
t.Fatalf("handler: %v", err)
}
st := fb.GetRootAsStateView(payload, 0)
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 1 {
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d", st.BagLen(), st.RackLength(), st.HintsRemaining())
}
game := st.Game(nil)
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "english" || game.ToMove() != 1 {
t.Fatalf("nested game decoded wrong: %+v", game)
}
}
func TestEnqueueRoundTripEncodesMatch(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"matched":true,"game":{"id":"g-9","variant":"english","status":"active","players":2,"to_move":0,"seats":[]}}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgLobbyEnqueue)
b := flatbuffers.NewBuilder(32)
v := b.CreateString("english")
fb.EnqueueRequestStart(b)
fb.EnqueueRequestAddVariant(b, v)
b.Finish(fb.EnqueueRequestEnd(b))
payload, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-1"})
if err != nil {
t.Fatalf("handler: %v", err)
}
m := fb.GetRootAsMatchResult(payload, 0)
if !m.Matched() {
t.Fatal("match result should be matched")
}
if g := m.Game(nil); g == nil || string(g.Id()) != "g-9" {
t.Fatalf("match game decoded wrong: %+v", g)
}
}
func TestDomainErrorSurfacesBackendCode(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"error":{"code":"not_your_turn","message":"nope"}}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgGameState)
b := flatbuffers.NewBuilder(32)
gid := b.CreateString("g-1")
fb.StateRequestStart(b)
fb.StateRequestAddGameId(b, gid)
b.Finish(fb.StateRequestEnd(b))
_, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-1"})
if err == nil {
t.Fatal("expected backend error")
}
code, ok := transcode.DomainCode(err)
if !ok || code != "not_your_turn" {
t.Fatalf("DomainCode = (%q, %v), want (not_your_turn, true)", code, ok)
}
}
+334
View File
@@ -0,0 +1,334 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: edge/v1/edge.proto
// Package scrabble.edge.v1 is the client <-> gateway Connect-RPC contract. It is
// deliberately minimal (ARCHITECTURE.md §2): a single unary Execute that routes
// by message_type, and a server-streaming Subscribe for the in-app live channel.
// The actual request/response and event bodies travel as FlatBuffers bytes in the
// payload fields (pkg/fbs). The session token rides in the Authorization header,
// not the envelope (no per-request signing — ARCHITECTURE.md §3).
package edgev1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// ExecuteRequest is the unary envelope. message_type selects the operation;
// payload is its FlatBuffers-encoded request body; request_id is an optional
// client correlation id echoed back.
type ExecuteRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
MessageType string `protobuf:"bytes,1,opt,name=message_type,json=messageType,proto3" json:"message_type,omitempty"`
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"`
RequestId string `protobuf:"bytes,3,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecuteRequest) Reset() {
*x = ExecuteRequest{}
mi := &file_edge_v1_edge_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecuteRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecuteRequest) ProtoMessage() {}
func (x *ExecuteRequest) ProtoReflect() protoreflect.Message {
mi := &file_edge_v1_edge_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecuteRequest.ProtoReflect.Descriptor instead.
func (*ExecuteRequest) Descriptor() ([]byte, []int) {
return file_edge_v1_edge_proto_rawDescGZIP(), []int{0}
}
func (x *ExecuteRequest) GetMessageType() string {
if x != nil {
return x.MessageType
}
return ""
}
func (x *ExecuteRequest) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
func (x *ExecuteRequest) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
// ExecuteResponse is the unary reply. result_code is "ok" on success or a stable
// error code; payload is the FlatBuffers-encoded response body (empty on error).
type ExecuteResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
ResultCode string `protobuf:"bytes,2,opt,name=result_code,json=resultCode,proto3" json:"result_code,omitempty"`
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecuteResponse) Reset() {
*x = ExecuteResponse{}
mi := &file_edge_v1_edge_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecuteResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecuteResponse) ProtoMessage() {}
func (x *ExecuteResponse) ProtoReflect() protoreflect.Message {
mi := &file_edge_v1_edge_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecuteResponse.ProtoReflect.Descriptor instead.
func (*ExecuteResponse) Descriptor() ([]byte, []int) {
return file_edge_v1_edge_proto_rawDescGZIP(), []int{1}
}
func (x *ExecuteResponse) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *ExecuteResponse) GetResultCode() string {
if x != nil {
return x.ResultCode
}
return ""
}
func (x *ExecuteResponse) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
// SubscribeRequest opens the live stream. It is empty: the session is taken from
// the Authorization header.
type SubscribeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SubscribeRequest) Reset() {
*x = SubscribeRequest{}
mi := &file_edge_v1_edge_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SubscribeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SubscribeRequest) ProtoMessage() {}
func (x *SubscribeRequest) ProtoReflect() protoreflect.Message {
mi := &file_edge_v1_edge_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead.
func (*SubscribeRequest) Descriptor() ([]byte, []int) {
return file_edge_v1_edge_proto_rawDescGZIP(), []int{2}
}
// Event is one live event. kind is the notification catalog kind; payload is its
// FlatBuffers-encoded body; event_id is a correlation id.
type Event struct {
state protoimpl.MessageState `protogen:"open.v1"`
Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"`
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"`
EventId string `protobuf:"bytes,3,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Event) Reset() {
*x = Event{}
mi := &file_edge_v1_edge_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Event) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Event) ProtoMessage() {}
func (x *Event) ProtoReflect() protoreflect.Message {
mi := &file_edge_v1_edge_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Event.ProtoReflect.Descriptor instead.
func (*Event) Descriptor() ([]byte, []int) {
return file_edge_v1_edge_proto_rawDescGZIP(), []int{3}
}
func (x *Event) GetKind() string {
if x != nil {
return x.Kind
}
return ""
}
func (x *Event) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
func (x *Event) GetEventId() string {
if x != nil {
return x.EventId
}
return ""
}
var File_edge_v1_edge_proto protoreflect.FileDescriptor
const file_edge_v1_edge_proto_rawDesc = "" +
"\n" +
"\x12edge/v1/edge.proto\x12\x10scrabble.edge.v1\"l\n" +
"\x0eExecuteRequest\x12!\n" +
"\fmessage_type\x18\x01 \x01(\tR\vmessageType\x12\x18\n" +
"\apayload\x18\x02 \x01(\fR\apayload\x12\x1d\n" +
"\n" +
"request_id\x18\x03 \x01(\tR\trequestId\"k\n" +
"\x0fExecuteResponse\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n" +
"\vresult_code\x18\x02 \x01(\tR\n" +
"resultCode\x12\x18\n" +
"\apayload\x18\x03 \x01(\fR\apayload\"\x12\n" +
"\x10SubscribeRequest\"P\n" +
"\x05Event\x12\x12\n" +
"\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n" +
"\apayload\x18\x02 \x01(\fR\apayload\x12\x19\n" +
"\bevent_id\x18\x03 \x01(\tR\aeventId2\xa5\x01\n" +
"\aGateway\x12N\n" +
"\aExecute\x12 .scrabble.edge.v1.ExecuteRequest\x1a!.scrabble.edge.v1.ExecuteResponse\x12J\n" +
"\tSubscribe\x12\".scrabble.edge.v1.SubscribeRequest\x1a\x17.scrabble.edge.v1.Event0\x01B'Z%scrabble/gateway/proto/edge/v1;edgev1b\x06proto3"
var (
file_edge_v1_edge_proto_rawDescOnce sync.Once
file_edge_v1_edge_proto_rawDescData []byte
)
func file_edge_v1_edge_proto_rawDescGZIP() []byte {
file_edge_v1_edge_proto_rawDescOnce.Do(func() {
file_edge_v1_edge_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_edge_v1_edge_proto_rawDesc), len(file_edge_v1_edge_proto_rawDesc)))
})
return file_edge_v1_edge_proto_rawDescData
}
var file_edge_v1_edge_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_edge_v1_edge_proto_goTypes = []any{
(*ExecuteRequest)(nil), // 0: scrabble.edge.v1.ExecuteRequest
(*ExecuteResponse)(nil), // 1: scrabble.edge.v1.ExecuteResponse
(*SubscribeRequest)(nil), // 2: scrabble.edge.v1.SubscribeRequest
(*Event)(nil), // 3: scrabble.edge.v1.Event
}
var file_edge_v1_edge_proto_depIdxs = []int32{
0, // 0: scrabble.edge.v1.Gateway.Execute:input_type -> scrabble.edge.v1.ExecuteRequest
2, // 1: scrabble.edge.v1.Gateway.Subscribe:input_type -> scrabble.edge.v1.SubscribeRequest
1, // 2: scrabble.edge.v1.Gateway.Execute:output_type -> scrabble.edge.v1.ExecuteResponse
3, // 3: scrabble.edge.v1.Gateway.Subscribe:output_type -> scrabble.edge.v1.Event
2, // [2:4] is the sub-list for method output_type
0, // [0:2] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_edge_v1_edge_proto_init() }
func file_edge_v1_edge_proto_init() {
if File_edge_v1_edge_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_edge_v1_edge_proto_rawDesc), len(file_edge_v1_edge_proto_rawDesc)),
NumEnums: 0,
NumMessages: 4,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_edge_v1_edge_proto_goTypes,
DependencyIndexes: file_edge_v1_edge_proto_depIdxs,
MessageInfos: file_edge_v1_edge_proto_msgTypes,
}.Build()
File_edge_v1_edge_proto = out.File
file_edge_v1_edge_proto_goTypes = nil
file_edge_v1_edge_proto_depIdxs = nil
}
+50
View File
@@ -0,0 +1,50 @@
syntax = "proto3";
// Package scrabble.edge.v1 is the client <-> gateway Connect-RPC contract. It is
// deliberately minimal (ARCHITECTURE.md §2): a single unary Execute that routes
// by message_type, and a server-streaming Subscribe for the in-app live channel.
// The actual request/response and event bodies travel as FlatBuffers bytes in the
// payload fields (pkg/fbs). The session token rides in the Authorization header,
// not the envelope (no per-request signing — ARCHITECTURE.md §3).
package scrabble.edge.v1;
option go_package = "scrabble/gateway/proto/edge/v1;edgev1";
// Gateway is the public edge service.
service Gateway {
// Execute runs one unary operation identified by message_type. Auth operations
// (auth.*) are unauthenticated and return a minted session; all others require
// a valid session token in the Authorization header.
rpc Execute(ExecuteRequest) returns (ExecuteResponse);
// Subscribe opens the in-app live-event stream for the authenticated session.
rpc Subscribe(SubscribeRequest) returns (stream Event);
}
// ExecuteRequest is the unary envelope. message_type selects the operation;
// payload is its FlatBuffers-encoded request body; request_id is an optional
// client correlation id echoed back.
message ExecuteRequest {
string message_type = 1;
bytes payload = 2;
string request_id = 3;
}
// ExecuteResponse is the unary reply. result_code is "ok" on success or a stable
// error code; payload is the FlatBuffers-encoded response body (empty on error).
message ExecuteResponse {
string request_id = 1;
string result_code = 2;
bytes payload = 3;
}
// SubscribeRequest opens the live stream. It is empty: the session is taken from
// the Authorization header.
message SubscribeRequest {}
// Event is one live event. kind is the notification catalog kind; payload is its
// FlatBuffers-encoded body; event_id is a correlation id.
message Event {
string kind = 1;
bytes payload = 2;
string event_id = 3;
}
@@ -0,0 +1,150 @@
// Code generated by protoc-gen-connect-go. DO NOT EDIT.
//
// Source: edge/v1/edge.proto
// Package scrabble.edge.v1 is the client <-> gateway Connect-RPC contract. It is
// deliberately minimal (ARCHITECTURE.md §2): a single unary Execute that routes
// by message_type, and a server-streaming Subscribe for the in-app live channel.
// The actual request/response and event bodies travel as FlatBuffers bytes in the
// payload fields (pkg/fbs). The session token rides in the Authorization header,
// not the envelope (no per-request signing — ARCHITECTURE.md §3).
package edgev1connect
import (
connect "connectrpc.com/connect"
context "context"
errors "errors"
http "net/http"
v1 "scrabble/gateway/proto/edge/v1"
strings "strings"
)
// This is a compile-time assertion to ensure that this generated file and the connect package are
// compatible. If you get a compiler error that this constant is not defined, this code was
// generated with a version of connect newer than the one compiled into your binary. You can fix the
// problem by either regenerating this code with an older version of connect or updating the connect
// version compiled into your binary.
const _ = connect.IsAtLeastVersion1_13_0
const (
// GatewayName is the fully-qualified name of the Gateway service.
GatewayName = "scrabble.edge.v1.Gateway"
)
// These constants are the fully-qualified names of the RPCs defined in this package. They're
// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
//
// Note that these are different from the fully-qualified method names used by
// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
// period.
const (
// GatewayExecuteProcedure is the fully-qualified name of the Gateway's Execute RPC.
GatewayExecuteProcedure = "/scrabble.edge.v1.Gateway/Execute"
// GatewaySubscribeProcedure is the fully-qualified name of the Gateway's Subscribe RPC.
GatewaySubscribeProcedure = "/scrabble.edge.v1.Gateway/Subscribe"
)
// GatewayClient is a client for the scrabble.edge.v1.Gateway service.
type GatewayClient interface {
// Execute runs one unary operation identified by message_type. Auth operations
// (auth.*) are unauthenticated and return a minted session; all others require
// a valid session token in the Authorization header.
Execute(context.Context, *connect.Request[v1.ExecuteRequest]) (*connect.Response[v1.ExecuteResponse], error)
// Subscribe opens the in-app live-event stream for the authenticated session.
Subscribe(context.Context, *connect.Request[v1.SubscribeRequest]) (*connect.ServerStreamForClient[v1.Event], error)
}
// NewGatewayClient constructs a client for the scrabble.edge.v1.Gateway service. By default, it
// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends
// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or
// connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewGatewayClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) GatewayClient {
baseURL = strings.TrimRight(baseURL, "/")
gatewayMethods := v1.File_edge_v1_edge_proto.Services().ByName("Gateway").Methods()
return &gatewayClient{
execute: connect.NewClient[v1.ExecuteRequest, v1.ExecuteResponse](
httpClient,
baseURL+GatewayExecuteProcedure,
connect.WithSchema(gatewayMethods.ByName("Execute")),
connect.WithClientOptions(opts...),
),
subscribe: connect.NewClient[v1.SubscribeRequest, v1.Event](
httpClient,
baseURL+GatewaySubscribeProcedure,
connect.WithSchema(gatewayMethods.ByName("Subscribe")),
connect.WithClientOptions(opts...),
),
}
}
// gatewayClient implements GatewayClient.
type gatewayClient struct {
execute *connect.Client[v1.ExecuteRequest, v1.ExecuteResponse]
subscribe *connect.Client[v1.SubscribeRequest, v1.Event]
}
// Execute calls scrabble.edge.v1.Gateway.Execute.
func (c *gatewayClient) Execute(ctx context.Context, req *connect.Request[v1.ExecuteRequest]) (*connect.Response[v1.ExecuteResponse], error) {
return c.execute.CallUnary(ctx, req)
}
// Subscribe calls scrabble.edge.v1.Gateway.Subscribe.
func (c *gatewayClient) Subscribe(ctx context.Context, req *connect.Request[v1.SubscribeRequest]) (*connect.ServerStreamForClient[v1.Event], error) {
return c.subscribe.CallServerStream(ctx, req)
}
// GatewayHandler is an implementation of the scrabble.edge.v1.Gateway service.
type GatewayHandler interface {
// Execute runs one unary operation identified by message_type. Auth operations
// (auth.*) are unauthenticated and return a minted session; all others require
// a valid session token in the Authorization header.
Execute(context.Context, *connect.Request[v1.ExecuteRequest]) (*connect.Response[v1.ExecuteResponse], error)
// Subscribe opens the in-app live-event stream for the authenticated session.
Subscribe(context.Context, *connect.Request[v1.SubscribeRequest], *connect.ServerStream[v1.Event]) error
}
// NewGatewayHandler builds an HTTP handler from the service implementation. It returns the path on
// which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewGatewayHandler(svc GatewayHandler, opts ...connect.HandlerOption) (string, http.Handler) {
gatewayMethods := v1.File_edge_v1_edge_proto.Services().ByName("Gateway").Methods()
gatewayExecuteHandler := connect.NewUnaryHandler(
GatewayExecuteProcedure,
svc.Execute,
connect.WithSchema(gatewayMethods.ByName("Execute")),
connect.WithHandlerOptions(opts...),
)
gatewaySubscribeHandler := connect.NewServerStreamHandler(
GatewaySubscribeProcedure,
svc.Subscribe,
connect.WithSchema(gatewayMethods.ByName("Subscribe")),
connect.WithHandlerOptions(opts...),
)
return "/scrabble.edge.v1.Gateway/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case GatewayExecuteProcedure:
gatewayExecuteHandler.ServeHTTP(w, r)
case GatewaySubscribeProcedure:
gatewaySubscribeHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedGatewayHandler returns CodeUnimplemented from all methods.
type UnimplementedGatewayHandler struct{}
func (UnimplementedGatewayHandler) Execute(context.Context, *connect.Request[v1.ExecuteRequest]) (*connect.Response[v1.ExecuteResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("scrabble.edge.v1.Gateway.Execute is not implemented"))
}
func (UnimplementedGatewayHandler) Subscribe(context.Context, *connect.Request[v1.SubscribeRequest], *connect.ServerStream[v1.Event]) error {
return connect.NewError(connect.CodeUnimplemented, errors.New("scrabble.edge.v1.Gateway.Subscribe is not implemented"))
}
+11
View File
@@ -2,8 +2,19 @@ go 1.26.3
use ./backend use ./backend
use (
./gateway
./pkg
)
// The scrabble-solver engine is consumed in-process as a library. Its module // 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 // 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. // versioned dependency via VCS; the workspace points it at the sibling checkout.
// CI clones that sibling next to this repository before building. // CI clones that sibling next to this repository before building.
replace scrabble-solver => ../scrabble-solver 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
+19 -1
View File
@@ -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= 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/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/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/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= 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/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/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-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/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/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI= 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/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-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/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/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-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/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-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/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/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/iliadenisov/alphabet v1.1.0 h1:d87N7Rmpjj9FgL7bvEaqLdaIaNch2hC6HvkbKGhn7Hk= 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/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= 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/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/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/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/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/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/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 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/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/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= 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/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/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= 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= 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 h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= 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.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/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/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= 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.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.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= 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/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= 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= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
+40
View File
@@ -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
+40
View File
@@ -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`.
+13
View File
@@ -0,0 +1,13 @@
version: v2
# Local plugins (go install via `make tools`) so generation needs no buf.build
# remote fetch. Output is committed; CI only builds it.
plugins:
- local: protoc-gen-go
out: proto
opt:
- paths=source_relative
- local: protoc-gen-go-grpc
out: proto
opt:
- paths=source_relative
+9
View File
@@ -0,0 +1,9 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
+205
View File
@@ -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;
}
+64
View File
@@ -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()
}
+119
View File
@@ -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()
}
+71
View File
@@ -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()
}
+71
View File
@@ -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()
}
+60
View File
@@ -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()
}
+60
View File
@@ -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()
}
+190
View File
@@ -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()
}
+60
View File
@@ -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()
}
+60
View File
@@ -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()
}
+80
View File
@@ -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()
}
+210
View File
@@ -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()
}
+81
View File
@@ -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()
}
+71
View File
@@ -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()
}
+116
View File
@@ -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()
}
+153
View File
@@ -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()
}
+120
View File
@@ -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()
}
+97
View File
@@ -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()
}
+60
View File
@@ -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()
}
+133
View File
@@ -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()
}
+97
View File
@@ -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()
}
@@ -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()
}
+105
View File
@@ -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()
}
+75
View File
@@ -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()
}
+18
View File
@@ -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
)
+31
View File
@@ -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=
+214
View File
@@ -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
}
+36
View File
@@ -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;
}
+141
View File
@@ -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",
}