Stage 6: gateway edge (Connect/FlatBuffers over h2c, platform/email/guest auth, sessions, rate-limit, admin passthrough, live push bridge)
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:
+52
-26
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user