diff --git a/backend/README.md b/backend/README.md index e3ba1a3..bfaf5f2 100644 --- a/backend/README.md +++ b/backend/README.md @@ -214,11 +214,12 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p | `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. | | `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. | | `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. | -| `BACKEND_SMTP_HOST` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). | -| `BACKEND_SMTP_PORT` | `587` | Email relay port. | -| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. | -| `BACKEND_SMTP_PASSWORD` | — | SMTP password. | -| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. | +| `BACKEND_SMTP_HOST` | — | Confirm-code relay host. **Empty selects the development log mailer** (the code is logged, not sent). | +| `BACKEND_SMTP_PORT` | `587` | Relay port. `465` selects implicit TLS; any other port uses STARTTLS. No client certificate is needed (the server cert is validated against the system roots). | +| `BACKEND_SMTP_USERNAME` | — | SMTP AUTH user; empty relays without authentication. | +| `BACKEND_SMTP_PASSWORD` | — | SMTP AUTH password. | +| `BACKEND_SMTP_FROM` | `no-reply@localhost` | From address. A deployed contour must use the prod domain (the relay only accepts its verified sender domain). | +| `BACKEND_PUBLIC_BASE_URL` | — | Canonical public origin (scheme + host) for links in the email. **Required when `BACKEND_SMTP_HOST` is set.** Never derived from a request Host header (anti-injection). | | `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. | | `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. | | `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. | diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 6318d5c..2e64761 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -244,3 +244,37 @@ func TestEmailLoginFlow(t *testing.T) { t.Errorf("returning login account = %s, want %s", acc2.ID, accountID) } } + +// TestEmailLoginProvisionsGuestUntilConfirmed covers the squat fix: an email-login +// account is a guest (reapable, so an abandoned never-confirmed login frees its +// address) until the code is confirmed, which promotes it to a durable account. +func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + mailer := &capturingMailer{} + svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") + email := "squat-" + uuid.NewString() + "@example.com" + + id, err := svc.RequestLoginCode(ctx, email, "", "en") + if err != nil { + t.Fatalf("request login code: %v", err) + } + before, err := store.GetByID(ctx, id) + if err != nil { + t.Fatalf("get before confirm: %v", err) + } + if !before.IsGuest { + t.Error("an unconfirmed email-login account must be a guest so it is reapable") + } + + if _, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)); err != nil { + t.Fatalf("login: %v", err) + } + after, err := store.GetByID(ctx, id) + if err != nil { + t.Fatalf("get after confirm: %v", err) + } + if after.IsGuest { + t.Error("confirming the login must clear the guest flag (promote to durable)") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5fa3631..70339a8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -232,9 +232,17 @@ arrive from a platform rather than completing a mandatory registration). `confirmed` flag. A synthetic `kind='robot'` identity backs each pooled robot opponent (§7). The **email confirm-code flow** binds an email to the authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute - TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a - development log mailer when none is configured) and, once verified, attaches a - confirmed email identity. Accounts and identities use application-generated + TTL, ≤ 5 attempts) is sent as a **branded HTML + plain-text email** through a + `Mailer` seam (go-mail over the shared relay — implicit TLS on port 465 or + STARTTLS, the server certificate validated against the system roots, no client + certificate; a development log mailer when no relay is configured) and, once + verified, attaches a confirmed email identity. Sends are throttled per recipient + (a 60-second cooldown and a five-per-hour cap). Links in the email are built from + a configured public base URL, never a request Host header (anti-injection). An + **email-login** account is created flagged `is_guest` and stays reapable until the + code is confirmed — so an abandoned, never-confirmed login frees its reserved + address — with confirming clearing the flag. Accounts and identities use + application-generated **UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time payment; no purchase flow yet) is carried on the account and ORed on a merge. - **Linking** is initiated from an authenticated profile and proves diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 2ab09fa..09dfed6 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -61,6 +61,12 @@ A **VK Mini App** launch works the same way: it authenticates from VK's signed l the name in the signed launch). While the theme preference is "auto" the app follows the VK client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry "couldn't load" screen applies inside VK. +**Email** sign-in (web) mails a branded six-digit confirmation code — localized +(en/ru), no images — to the address; entering it signs the player in. A new address +is provisioned on the first request but only becomes a durable account once the code +is confirmed, so an abandoned, never-confirmed attempt is cleaned up and its address +freed. Sends are rate-limited (a short cooldown between codes and a small hourly cap +per address), so a mistyped address or an impatient tap cannot flood an inbox. Telegram runs a **single bot**: every player uses the same bot, and all of its chat and out-of-app notifications are written in the player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 2857b63..1ca51cd 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -65,7 +65,14 @@ launch-параметрам VK (их проверяет gateway), и при пе так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран тихого повтора «не удалось -загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним +загрузить» действует и внутри VK. +**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения — +локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом +запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная, +неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая +пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап +не завалили почтовый ящик. +Telegram держит **единого бота**: все игроки пользуются одним и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный **промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой, diff --git a/docs/TESTING.md b/docs/TESTING.md index 0d2b36a..e22de9c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -69,7 +69,10 @@ tests or touching CI. content and block-visibility rules, the nudge turn/rate-limit rules, the invitation flow (all-accept starts the game, decline cancels, lazy expiry, inviter-only cancel), and the email confirm-code flow (request/confirm, taken - email, expiry and attempt-cap) with a fixture mailer. It also covers the + email, expiry and attempt-cap, and the guest-until-confirmed login) with a fixture + mailer. The branded email template render (en/ru subject, code and body) and the + per-recipient send-rate limiter (cooldown + hourly cap) are pure account-package + unit tests, needing no database. It also covers the **befriend-an-opponent** gate (a request needs a shared game), the **permanent decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem, self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and