Add the VK Mini App logo next to the Telegram one on the landing hero,
linked via the new VITE_VK_APP_LINK build-arg (full URL, wired through
compose, CI and prod-deploy from TEST_/PROD_VITE_VK_APP_LINK).
Also restore the landing's Telegram link itself: commit 57c778f
collapsed the per-language vars to VITE_TELEGRAM_GAME_CHANNEL_NAME in
compose/CI but left gateway/Dockerfile with the stale _EN/_RU ARGs and
without the plain one, so the built bundle saw the var as undefined and
dead-code-eliminated the whole channel-link branch — deployed landings
(prod included) have shown no Telegram logo since.
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 embeds the static UI build (go:embed, baked
in by the gateway image's node stage) and serves a landing page at / and the game
SPA at /app/ (web), /telegram/ (the Telegram Mini App) and /vk/ (the VK Mini App) — the single-origin model.
Hash-named /assets/* are served immutable; the HTML shells are no-cache. It can also serve the
backend's admin console at /_gm behind HTTP Basic-Auth for a local non-caddy run;
in the deployed contour the front caddy owns /_gm (see
../deploy). See
../docs/ARCHITECTURE.md §2, §3, §10, §12, §13.
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) + the rejection tracker + the temporary IP banlist
internal/connector/ # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing
internal/push/ # live-event fan-out hub (per-user client streams)
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/ + /vk/
The FlatBuffers payloads and the backend push proto are the shared wire
contracts in ../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.
auth.telegram validates the Mini App initData by calling the Telegram validator
(GATEWAY_VALIDATOR_ADDR), which holds the bot token (HMAC); out-of-app push for
recipients with no live in-app stream goes to the remote bot over the reverse mTLS
bot-link (GATEWAY_BOTLINK_ADDR, fire-and-forget), and the backend admin broadcasts
arrive on the gateway's plaintext relay (GATEWAY_BOTLINK_RELAY_ADDR) which forwards
them down the same link and awaits the bot's ack (ARCHITECTURE.md §10/§12). When
GATEWAY_VALIDATOR_ADDR is unset Telegram auth is disabled; when GATEWAY_BOTLINK_ADDR
is unset the bot channel (out-of-app push + admin relay) is disabled.
auth.vk validates a VK Mini App launch in-process (internal/vkauth): it verifies the
signed vk_* launch parameters against the VK app's protected key (GATEWAY_VK_APP_SECRET) —
HMAC-SHA256 over the sorted params, base64url — a pure offline check with no side-service or VK API
round-trip, then forwards the trusted vk_user_id (and the client-read display name, which VK omits
from the signed params) to the backend. When GATEWAY_VK_APP_SECRET is unset VK auth is disabled
(auth.vk is unregistered).
The message-type catalog: auth.telegram, auth.vk, auth.guest,
auth.email.request, auth.email.login, profile.get, game.submit_play,
game.state, lobby.enqueue, lobby.poll, chat.post, chat.read and the play-loop ops;
live events
your_turn, opponent_moved, chat_message, nudge, match_found (the game events —
and game_over/notify — carry the state delta the client applies without a game.state
refetch). The social/account/history ops —
friends.* (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem),
blocks.*, invitation.* (list/create/accept/decline/cancel), profile.update,
stats.get, game.gcg, and the notify live event — go through the identical
transcode pattern (transcode_social.go). Account linking & merge
— link.email.request/confirm/merge and link.telegram.confirm/merge
(transcode_link.go); the telegram ops validate the Login Widget payload via the
validator (ValidateLoginWidget) and forward the trusted external_id. These
superseded the former email.bind.* ops, which were removed.
Configuration
| Variable | Default | Notes |
|---|---|---|
GATEWAY_HTTP_ADDR |
:8081 |
public Connect/h2c listener (also serves the admin console at /_gm) |
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 console at /_gm |
GATEWAY_VALIDATOR_ADDR |
unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
GATEWAY_VK_APP_SECRET |
unset | VK app protected key; enables in-process VK Mini App launch-signature verification (auth.vk) |
GATEWAY_BOTLINK_ADDR |
unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
GATEWAY_BOTLINK_RELAY_ADDR |
unset | plaintext internal listener serving the backend admin SendToUser/SendToGameChannel relay |
GATEWAY_BOTLINK_TLS_CERT / _KEY / _CA |
unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when GATEWAY_BOTLINK_ADDR is set) |
GATEWAY_BOTLINK_SEND_TIMEOUT |
5s |
admin relay wait for the bot ack before reporting not-delivered |
GATEWAY_SESSION_TTL |
10m |
cached session lifetime |
GATEWAY_SESSION_CACHE_MAX |
50000 |
cached session cap |
GATEWAY_PUSH_HEARTBEAT_INTERVAL |
10s |
live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) |
GATEWAY_MAX_BODY_BYTES |
1048576 |
caps one request body and one Connect message read; an oversized Execute is refused with resource_exhausted |
GATEWAY_ABUSE_BAN_ENABLED |
false |
enable the temporary IP ban (prod-only — keys by real client IP, off in the shared-NAT test contour) |
GATEWAY_ABUSE_BAN_THRESHOLD |
100 |
rate-limiter rejections within the window that ban an IP |
GATEWAY_ABUSE_BAN_WINDOW |
2m |
rolling window the rejection strikes accumulate over |
GATEWAY_ABUSE_BAN_DURATION |
15m |
length of a rejection-earned ban (tripwire 1h, honeytoken 24h are fixed) |
GATEWAY_HONEYTOKEN |
unset | planted bearer value; presenting it bans the caller and raises an alarm |
GATEWAY_SERVICE_NAME |
scrabble-gateway |
OpenTelemetry service.name |
GATEWAY_OTEL_TRACES_EXPORTER |
none |
none, stdout or otlp (gRPC; endpoint from OTEL_EXPORTER_OTLP_*) |
GATEWAY_OTEL_METRICS_EXPORTER |
none |
none, stdout or otlp |
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated
300/min·user (burst 80, sized for multi-device play), admin
60/min·IP (burst 20, guarding the /_gm mount ahead of its Basic-Auth),
email-code 5/10 min·IP (burst 2).
Every rejection increments gateway_rate_limited_total{class}
(user/public/email/admin) and logs one Debug line; a reporter drains the
per-key rejection tracker every 30 s, emits a Warn summary per throttled key and
posts the report to the backend (/api/v1/internal/ratelimit/report), feeding
the admin console's throttled view and the high-rate auto-flag.
Temporary IP ban (prod-only, GATEWAY_ABUSE_BAN_ENABLED): a fail2ban-style block
keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed classes),
a honeypot decoy-path hit (the contour caddy tags decoy paths with
X-Scrabble-Honeypot), and a honeytoken (GATEWAY_HONEYTOKEN). A banned IP is
refused with 429 by the abuseGuard edge middleware before any work — covering the
Connect edge, the live stream and the static SPA/landing. Each ban increments
gateway_abuse_banned_total{reason} (rejections/tripwire/honeytoken). The ban
is in-memory (resets on restart); it is off by default because it keys by the real
client IP, which the shared-NAT test contour does not expose (detection still logs
there, only the ban action is gated). The gateway syncs its active set to the backend
every 30 s (/api/v1/internal/bans/sync) for the console's Active IP bans panel
and applies the operator unbans the response returns.
Run
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):
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 (make -C pkg fbs).
Tests
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.