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:
@@ -126,6 +126,72 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
|
||||
return s.store.GetByID(ctx, accountID)
|
||||
}
|
||||
|
||||
// RequestLoginCode issues a login confirm-code to the account that owns email,
|
||||
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
|
||||
// the unauthenticated email-login entry point (Stage 6) and, unlike RequestCode,
|
||||
// does not refuse an already-confirmed email — that is the ordinary returning-user
|
||||
// login. The code is mailed to the address, so only its real owner can complete
|
||||
// the login. It returns the target account id for the subsequent LoginWithCode.
|
||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email string) (uuid.UUID, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
acc, err := s.store.ProvisionByIdentity(ctx, KindEmail, addr)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
code, hash, err := generateCode()
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
if err := s.store.replacePendingConfirmation(ctx, acc.ID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
subject := "Your Scrabble login code"
|
||||
body := fmt.Sprintf("Your login code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
||||
if err := s.mailer.Send(ctx, addr, subject, body); err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
return acc.ID, nil
|
||||
}
|
||||
|
||||
// LoginWithCode verifies a login code for email and returns the owning account,
|
||||
// marking the email identity confirmed on first success (idempotent for a
|
||||
// returning user). It mirrors ConfirmCode's checks but updates the existing
|
||||
// identity rather than inserting one, since RequestLoginCode already provisioned
|
||||
// it. It returns ErrNotFound when no account owns the email.
|
||||
func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (Account, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
acc, err := s.store.findByIdentity(ctx, KindEmail, addr)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
conf, err := s.store.latestPendingConfirmation(ctx, acc.ID, addr)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if s.now().After(conf.expiresAt) {
|
||||
return Account{}, ErrCodeExpired
|
||||
}
|
||||
if conf.attempts >= emailCodeMaxAttempts {
|
||||
return Account{}, ErrTooManyAttempts
|
||||
}
|
||||
if hashCode(code) != conf.codeHash {
|
||||
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return Account{}, ErrCodeMismatch
|
||||
}
|
||||
if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return s.store.GetByID(ctx, acc.ID)
|
||||
}
|
||||
|
||||
// emailConfirmation is a pending confirm-code row in domain form.
|
||||
type emailConfirmation struct {
|
||||
id uuid.UUID
|
||||
@@ -252,6 +318,34 @@ func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accoun
|
||||
return nil
|
||||
}
|
||||
|
||||
// confirmEmailLogin consumes the login code and marks the existing email
|
||||
// identity confirmed, inside one transaction. The identity already exists (a
|
||||
// login provisioned it), so this updates rather than inserts and is idempotent
|
||||
// for a returning user whose identity is already confirmed.
|
||||
func (s *Store) confirmEmailLogin(ctx context.Context, confirmationID, accountID uuid.UUID, email string, now time.Time) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
upd := table.EmailConfirmations.
|
||||
UPDATE(table.EmailConfirmations.ConsumedAt).
|
||||
SET(postgres.TimestampzT(now)).
|
||||
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("consume login code: %w", err)
|
||||
}
|
||||
confirm := table.Identities.
|
||||
UPDATE(table.Identities.Confirmed).
|
||||
SET(postgres.Bool(true)).
|
||||
WHERE(
|
||||
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))).
|
||||
AND(table.Identities.ExternalID.EQ(postgres.String(email))),
|
||||
)
|
||||
if _, err := confirm.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("confirm email identity: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeEmail parses and lower-cases an email address, or returns ErrInvalidEmail.
|
||||
func normalizeEmail(email string) (string, error) {
|
||||
addr, err := mail.ParseAddress(strings.TrimSpace(email))
|
||||
|
||||
Reference in New Issue
Block a user