Files
scrabble-game/gateway
Ilia Denisov 1a95a5f2cb
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
feat(offline): app-shell precache service worker (vite-plugin-pwa)
Migrate the former install-only public/sw.js to a custom (injectManifest)
service worker built from ui/src/sw.ts by vite-plugin-pwa. It precaches the
app shell + hashed assets (Workbox) so an installed web PWA cold-launches with
no network, and falls in-scope navigations back to the precached shell (the
hash router resolves the route client-side). This is the C1 prerequisite for a
usable offline mode: without it a cold offline launch cannot load the bundle.

- ui/src/sw.ts: skipWaiting + clientsClaim + cleanupOutdatedCaches +
  precacheAndRoute(__WB_MANIFEST) + a NavigationRoute fallback to index.html,
  deny-listing the RPC path and /_gm. The Connect stream and API POSTs are
  never precached nor intercepted.
- vite.config.ts: VitePWA(injectManifest); manifest:false (we ship our own),
  injectRegister:false (registration stays manual + web-only in pwa.svelte.ts),
  disabled in the mock build (Playwright unperturbed); landing + polyfills
  excluded from the precache.
- Removed public/sw.js; the registration path (dist/sw.js at /app/) is unchanged.
- New dev deps: vite-plugin-pwa + workbox-{core,precaching,routing}.

Docs: ARCHITECTURE + gateway/README. Offline cold-launch is contour-verified
(the mock e2e harness disables the SW).
2026-07-06 11:06:59 +02:00
..

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. The web SPA is an installable PWA: it serves manifest.webmanifest (unhashed from ui/public/, with the .webmanifest MIME type registered in-process — the distroless image has no /etc/mime.types) and the app-shell sw.js (built from ui/src/sw.ts by vite-plugin-pwa, which precaches the shell + assets so an installed PWA cold-launches offline). 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).

link.vk.confirm/merge link a VK identity from a browser (not a Mini App) via VK ID web login (internal/vkid): the SPA runs the VK ID raw OAuth 2.1 flow (PKCE, no @vkid/sdk) and the gateway completes the confidential code exchange at id.vk.com under a SEPARATE VK "Web" app's protected key — the only op here that calls VK (the Mini App path above is offline). All three GATEWAY_VK_ID_* unset leaves the ops 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, link.telegram.confirm/merge and link.vk.confirm/merge (transcode_link.go); the telegram ops validate the Login Widget payload via the validator (ValidateLoginWidget), the vk ops complete the VK ID web code exchange via internal/vkid, and both 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_VK_ID_APP_ID / GATEWAY_VK_ID_CLIENT_SECRET / GATEWAY_VK_ID_REDIRECT_URL unset VK ID "Web" app credentials for VK web-login linking (link.vk.*): the server-side confidential OAuth 2.1 code exchange. A separate VK app from GATEWAY_VK_APP_SECRET; all three required to enable the ops
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.