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
+52 -26
View File
@@ -11,12 +11,13 @@ not-yet-implemented components are marked *(planned)*.
Three executables plus per-platform side-services:
- **`gateway`** *(planned)* — the only public ingress. Performs anti-abuse
(rate limiting), authenticates the player against the originating platform
(or an email/guest session), resolves the internal `user_id`, and forwards
authenticated traffic to `backend` with an `X-User-ID` header. Hosts an admin
surface behind HTTP Basic Auth. Bridges live events from `backend` to the
client.
- **`gateway`** — the only public ingress (module `scrabble/gateway`). Performs
anti-abuse (rate limiting), authenticates the player against the originating
platform (or an email/guest session), resolves the internal `user_id`, and
forwards authenticated traffic to `backend` with an `X-User-ID` header. Hosts an
admin surface behind HTTP Basic Auth. Bridges live events from `backend` to the
client. The shared wire contracts (the push proto and the FlatBuffers edge
payloads) live in `scrabble/pkg`, imported by both `gateway` and `backend`.
- **`backend`** — internal-only service that owns every domain concern:
identity/sessions, accounts and linking, lobby and matchmaking, the game
runtime, the robot opponent, chat, notifications, statistics, history, and
@@ -50,7 +51,17 @@ dropped). Horizontal scaling is explicit future work.
- **client ↔ gateway**: **Connect-RPC + FlatBuffers** over HTTP/2 cleartext
(`h2c`). Binary payloads, server-streaming for the in-app live channel,
first-class JS clients (`@connectrpc/connect-web` + the `flatbuffers` npm
package). The contract is kept minimal.
package). The contract is kept minimal: a single `Gateway` service (defined in
`gateway/proto/edge/v1`) with `Execute(message_type, payload, request_id)` for
unary operations and `Subscribe` for the live stream. The proto envelope is a
thin carrier; the real request/response and event bodies are **FlatBuffers**
tables (`pkg/fbs`, the `scrabblefb` namespace) inside the `payload` bytes, which
the gateway transcodes to and from the backend's JSON. The session token rides
in the `Authorization: Bearer` header (there is no per-request signing, §3);
auth operations are unauthenticated and return the minted token. A unary
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
200); only edge failures (rate limit, missing session, unknown type, internal)
surface as Connect error codes.
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
`X-User-ID` for authenticated requests; `backend` never re-derives identity
from the body.
@@ -76,9 +87,13 @@ arrive from a platform rather than completing a mandatory registration).
token (never the plaintext), keeps a warmed in-memory cache for fast
resolution, and treats sessions as **revoke-only** — they have no TTL and live
until explicitly revoked (`status``revoked`).
- **Guest** = ephemeral web session (no platform, no email): session-only,
nothing persisted; restricted to auto-match, with no friends and no
stats/history. Platform users are auto-provisioned **durable** accounts.
- **Guest** = ephemeral web session (no platform, no email). A guest is backed by
a durable `accounts` row flagged `is_guest` and carrying **no identity** — the
row is a technical necessity (the `sessions` and `game_players` foreign keys
require one, the same way the robot pool is durable), not a profile: no
friends, statistics or history are kept for it, and it is restricted to
auto-match. Platform and email users are auto-provisioned **durable** accounts
with an identity. (Reaping abandoned guest rows is deferred — PLAN.md TODO-3.)
## 4. Accounts, identities, linking & merge
@@ -231,9 +246,9 @@ requires (there is no DM surface; chat is per-game).
auto-match with the seat order randomised for first-move fairness. The pool is
lost on restart (players re-queue) and is anonymous, so it does not consult
blocks. After **10 s** with no human a background reaper substitutes a pooled
robot (§7) and starts the game. A queued player learns of a pairing or a
substitution through the matchmaker's `Poll`, the interim delivery seam until the
live match-found notification (§10).
robot (§7) and starts the game. On a pairing or substitution the matchmaker
emits a **match-found** notification (§10), delivered over the live stream;
`Poll` remains as a fallback for a client that is not currently streaming.
- **Friends**: a **request → accept** graph (one `friendships` table) — add by
friend list or internal ID now, by platform deep-link with Stage 8. Declining or
cancelling removes the pending request; blocking someone severs an existing
@@ -271,7 +286,8 @@ requires (there is no DM surface; chat is per-game).
Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary
keys are application-generated **UUIDv7**.
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window
columns `away_start`/`away_end` and the hint wallet `hint_balance`),
columns `away_start`/`away_end` and the hint wallet `hint_balance`; Stage 6's
migration `00005` added the `is_guest` flag for ephemeral guest rows),
`identities` (platform/email/robot identities, unique `(kind, external_id)`;
Stage 5's migration `00004` admits the `robot` kind),
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables
@@ -290,8 +306,9 @@ requires (there is no DM surface; chat is per-game).
Each game is serialised by a per-game lock; a persistence failure evicts the
live game so the next access rebuilds from the journal. `game_players` records
each seat's account, running score, hints used and winner flag.
- **Statistics** (`account_stats`, recomputed on each finish, durable accounts
only — guests never appear): wins, losses, **draws**, max points in a game, and
- **Statistics** (`account_stats`, recomputed on each finish for durable
non-guest accounts only — the finish-time recompute skips any `is_guest`
seat): wins, losses, **draws**, max points in a game, and
max points for a single **move** (which already folds in every word the move
formed plus the all-tiles bonus). A tie increments draws only; a resignation or
timeout is a loss for the acting player.
@@ -319,15 +336,21 @@ does not cover.
## 10. Notifications
Two channels: **platform-native push** (out-of-app, via the platform
side-service — your-turn, nudge) and the **in-app live stream** (chat,
opponent-moved, while the app is open). Backend emits notification intents;
delivery fans out to the appropriate channel. A **match-found** event (a human
pairing or a robot substitution in auto-match, §8) belongs to the same fabric.
Stage 4 **persists** the notification-worthy events (chat messages and nudges) but
does not yet deliver them, and Stage 5's match-found has no live channel yet: the
gRPC stream to the gateway and the platform push arrive in Stage 6 / 8. Until then
a waiting client retrieves its started game by polling the matchmaker (`Poll`).
Two channels: the **in-app live stream** (delivered from Stage 6) and
**platform-native push** (out-of-app, via the platform side-service — Stage 8).
The backend emits notification intents through an in-process hub
(`internal/notify`, a `Publisher` seam installed on the game, social and lobby
services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
`pkg/proto/push/v1`) carries every event, and the gateway fans them out by
`user_id` to each client's Connect `Subscribe` stream while the app is open. The
catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so
robot-driver and timeout-sweeper moves emit too), **chat-message** and **nudge**
(from the social service), and **match-found** (from the matchmaker, §8). Event
payloads are FlatBuffers-encoded by the backend and forwarded verbatim. A client
that is not currently streaming falls back to the matchmaker's `Poll` for
match-found. Out-of-app platform push (your-turn, nudge) is wired in Stage 8;
session-revocation events and cursor-based stream resume are deferred
(single-instance MVP).
## 11. Observability
@@ -342,6 +365,9 @@ a waiting client retrieves its started game by polling the matchmaker (`Poll`).
client-measured RTT piggybacked on the next request is a later enhancement.
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
database answers a bounded ping and the session cache is warmed).
- The backend serves a **second listener** — a gRPC server
(`BACKEND_GRPC_ADDR`, default `:9090`) for the live-event push stream to the
gateway — alongside the HTTP listener; both start together and stop on signal.
## 12. Security boundaries
@@ -351,7 +377,7 @@ a waiting client retrieves its started game by polling the matchmaker (`Poll`).
| Platform credential validation, session minting | gateway |
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
| Admin authentication | gateway Basic Auth → backend admin endpoints |
| Admin authentication | gateway validates HTTP Basic Auth (`GATEWAY_ADMIN_*`), then reverse-proxies to backend admin endpoints |
| backend ↔ gateway trust | the network (only gateway may reach backend) |
This is an explicit, accepted MVP risk: compromise of the gateway↔backend