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
+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)
}
}