12e616ceae
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The merchant accepts payments as a sole proprietor on НПД, which is outside 54-ФЗ: there is no online cash register, YooKassa does not serve receipts for that regime at all (checked with their support), and each operation is reported by the merchant to «Мой налог», which issues the чек. So no `receipt` is sent with a payment or a refund. This also removes a failure class rather than just code: a malformed receipt was an API error at payment creation, which broke the purchase outright. The fiscal code is kept dormant rather than deleted. All of it now sits behind one switch, `BACKEND_YOOKASSA_VAT_CODE`: unset — the default — builds and sends nothing; a 54-ФЗ rate code turns «Чеки от ЮKassa» back on unchanged. The return is foreseeable, which is why the switch exists: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back in force, at which point this is a deploy-variable edit instead of writing the integration again. The D36 email anchor still gates a direct purchase. It had two justifications — a recovery anchor and the receipt address — and only the second is gone; without an email a paying customer who loses the account loses the chips with it. What was missing is that the rule was enforced but never communicated: the wallet showed the packs to a player signed in through VK or Telegram in a browser, and tapping Buy produced a bare "something went wrong". It now says "add an email in your profile" in the buy tab instead, linking to the profile; spending already-earned chips is untouched. The predicate is a pure function so it is covered by the node-env unit tests rather than needing a browser. Tests: the receipt-off default is pinned by an integration test asserting a purchase carries no receipt, and the dormant path by one that switches a VAT code on and checks the receipt reappears with the right fiscal attributes; plus unit coverage for the enable predicate and the wallet's email rule. A note for whoever runs the numbers next: the shared (svelte + i18n) chunk is now 40 bytes under its 31 KB gzip budget. Decision D51 revised.
357 lines
14 KiB
Go
357 lines
14 KiB
Go
// Package config loads and validates the backend's runtime configuration from
|
|
// the process environment.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/game"
|
|
"scrabble/backend/internal/lobby"
|
|
"scrabble/backend/internal/postgres"
|
|
"scrabble/backend/internal/ratewatch"
|
|
"scrabble/backend/internal/robokassa"
|
|
"scrabble/backend/internal/robot"
|
|
"scrabble/backend/internal/telemetry"
|
|
"scrabble/backend/internal/yookassa"
|
|
)
|
|
|
|
// Config holds the backend's runtime configuration.
|
|
type Config struct {
|
|
// HTTPAddr is the listen address of the HTTP listener (host:port).
|
|
HTTPAddr string
|
|
// GRPCAddr is the listen address of the gRPC push listener (host:port) that
|
|
// streams live events to the gateway.
|
|
GRPCAddr string
|
|
// LogLevel is the zap log level: "debug", "info", "warn" or "error".
|
|
LogLevel string
|
|
// Postgres configures the primary database pool.
|
|
Postgres postgres.Config
|
|
// Telemetry configures the OpenTelemetry providers.
|
|
Telemetry telemetry.Config
|
|
// Game configures the game subsystem (dictionaries, sweeper, live-game cache).
|
|
Game game.Config
|
|
// Lobby configures matchmaking robot substitution (wait window, reaper cadence).
|
|
Lobby lobby.Config
|
|
// Robot configures the robot opponent driver (scan cadence).
|
|
Robot robot.Config
|
|
// RateWatch tunes the conservative high-rate auto-flag applied to the
|
|
// gateway's rate-limiter rejection reports.
|
|
RateWatch ratewatch.Config
|
|
// SMTP configures the email relay used for confirm-codes. An empty Host
|
|
// selects the development log mailer (the code is logged, not sent).
|
|
SMTP account.SMTPConfig
|
|
// PublicBaseURL is the canonical public origin (scheme + host, e.g.
|
|
// https://erudit-game.ru) used to build absolute links in outgoing email — the
|
|
// confirm deeplink and the footer landing link. It is deliberately not derived
|
|
// from a request Host header, which would let an attacker inject a phishing link
|
|
// into the email. Required whenever an SMTP relay is configured.
|
|
PublicBaseURL string
|
|
// ConnectorAddr is the gRPC address of the Telegram platform connector
|
|
// side-service, used by the admin console to send operator broadcasts. Empty
|
|
// disables broadcasts (the admin broadcast actions report "not configured").
|
|
ConnectorAddr string
|
|
// GuestReapInterval is the cadence of the abandoned-guest reaper sweep.
|
|
GuestReapInterval time.Duration
|
|
// GuestRetention is the account age past which an unused guest (no game seat)
|
|
// is eligible for deletion by the reaper.
|
|
GuestRetention time.Duration
|
|
// ExportSignKey signs the finished-game export download URLs. Empty leaves
|
|
// the export-URL endpoints disabled (503 on mint, 404 on download).
|
|
ExportSignKey string
|
|
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
|
// http://renderer:8090). Empty disables the PNG export artifact.
|
|
RendererURL string
|
|
// YooKassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
|
|
// (D42). An empty set leaves the direct order and notification endpoints unregistered and falls
|
|
// the direct rail back to Robokassa.
|
|
YooKassa yookassa.Shops
|
|
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
|
|
// Unset (0) sends no receipt at all — the state a merchant outside 54-ФЗ runs in, which is the
|
|
// default; setting a rate turns fiscal receipts back on without a code change.
|
|
YooKassaVatCode int
|
|
// Robokassa configures the retired direct-rail payment provider — one merchant shop per channel
|
|
// (D42). It is dormant: no deployment sets its credentials, so the set is empty and the rail
|
|
// resolves to YooKassa. Kept wired so restoring Robokassa is a credentials change, not a code
|
|
// change — see backend/internal/robokassa/README.md.
|
|
Robokassa robokassa.Shops
|
|
}
|
|
|
|
// Defaults applied when the corresponding environment variable is unset.
|
|
const (
|
|
defaultHTTPAddr = ":8080"
|
|
defaultGRPCAddr = ":9090"
|
|
defaultLogLevel = "info"
|
|
defaultGuestReapInterval = time.Hour
|
|
defaultGuestRetention = 30 * 24 * time.Hour
|
|
)
|
|
|
|
// Load reads the configuration from the environment, applies defaults for unset
|
|
// variables, and validates the result.
|
|
func Load() (Config, error) {
|
|
pg := postgres.DefaultConfig()
|
|
pg.DSN = os.Getenv("BACKEND_POSTGRES_DSN")
|
|
var err error
|
|
if pg.MaxOpenConns, err = envInt("BACKEND_POSTGRES_MAX_OPEN_CONNS", pg.MaxOpenConns); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if pg.MaxIdleConns, err = envInt("BACKEND_POSTGRES_MAX_IDLE_CONNS", pg.MaxIdleConns); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if pg.ConnMaxLifetime, err = envDuration("BACKEND_POSTGRES_CONN_MAX_LIFETIME", pg.ConnMaxLifetime); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if pg.OperationTimeout, err = envDuration("BACKEND_POSTGRES_OPERATION_TIMEOUT", pg.OperationTimeout); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
tel := telemetry.DefaultConfig()
|
|
tel.ServiceName = envOr("BACKEND_SERVICE_NAME", tel.ServiceName)
|
|
tel.TracesExporter = envOr("BACKEND_OTEL_TRACES_EXPORTER", tel.TracesExporter)
|
|
tel.MetricsExporter = envOr("BACKEND_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
|
|
|
|
gm := game.DefaultConfig()
|
|
gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
|
|
gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion)
|
|
gm.DictSeedDir = envOr("BACKEND_DICT_SEED_DIR", gm.DictSeedDir)
|
|
if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if gm.CacheTTL, err = envDuration("BACKEND_GAME_CACHE_TTL", gm.CacheTTL); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
lb := lobby.DefaultConfig()
|
|
if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if lb.RobotWaitJitter, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT_JITTER", lb.RobotWaitJitter); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
rb := robot.DefaultConfig()
|
|
if rb.DriveInterval, err = envDuration("BACKEND_ROBOT_DRIVE_INTERVAL", rb.DriveInterval); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
rw := ratewatch.DefaultConfig()
|
|
if rw.FlagThreshold, err = envInt("BACKEND_HIGHRATE_FLAG_THRESHOLD", rw.FlagThreshold); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if rw.FlagWindow, err = envDuration("BACKEND_HIGHRATE_FLAG_WINDOW", rw.FlagWindow); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
guestRetention, err := envDuration("BACKEND_GUEST_RETENTION", defaultGuestRetention)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
smtp := account.SMTPConfig{
|
|
Host: os.Getenv("BACKEND_SMTP_HOST"),
|
|
Port: envOr("BACKEND_SMTP_PORT", "587"),
|
|
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
|
|
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
|
|
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
|
|
TLS: os.Getenv("BACKEND_SMTP_TLS"),
|
|
AdminFrom: os.Getenv("BACKEND_SMTP_ADMIN_FROM"),
|
|
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
|
}
|
|
|
|
// YooKassa direct rail: one merchant shop per channel (D42). A shop missing either credential is
|
|
// dropped, so the rail stays dormant until a channel is fully configured.
|
|
ykShops := yookassa.Shops{}
|
|
for channel, prefix := range map[string]string{
|
|
yookassa.ChannelWeb: "BACKEND_YOOKASSA_WEB",
|
|
yookassa.ChannelAndroid: "BACKEND_YOOKASSA_ANDROID",
|
|
} {
|
|
if shop := yookassaShop(prefix); shop.Configured() {
|
|
ykShops[channel] = shop
|
|
}
|
|
}
|
|
vatCode, err := envInt("BACKEND_YOOKASSA_VAT_CODE", yookassa.VatCodeOff)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
// Robokassa direct rail: retired but kept wired (see backend/internal/robokassa/README.md). No
|
|
// deployment sets these, so the set is empty and the direct rail resolves to YooKassa; restoring
|
|
// the credentials revives it without a code change. The legacy single-shop vars seed the web
|
|
// channel; the per-channel vars add the rest.
|
|
shops := robokassa.Shops{}
|
|
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
|
|
if web.MerchantLogin == "" {
|
|
web = robokassaShop("BACKEND_ROBOKASSA") // legacy single-shop credentials seed the web channel
|
|
}
|
|
if web.MerchantLogin != "" {
|
|
shops[robokassa.ChannelWeb] = web
|
|
}
|
|
if android := robokassaShop("BACKEND_ROBOKASSA_ANDROID"); android.MerchantLogin != "" {
|
|
shops[robokassa.ChannelAndroid] = android
|
|
}
|
|
|
|
c := Config{
|
|
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
|
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
|
|
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
|
|
Postgres: pg,
|
|
Telemetry: tel,
|
|
Game: gm,
|
|
Lobby: lb,
|
|
Robot: rb,
|
|
RateWatch: rw,
|
|
SMTP: smtp,
|
|
PublicBaseURL: os.Getenv("BACKEND_PUBLIC_BASE_URL"),
|
|
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
|
GuestReapInterval: guestReapInterval,
|
|
GuestRetention: guestRetention,
|
|
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
|
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
|
YooKassa: ykShops,
|
|
YooKassaVatCode: vatCode,
|
|
Robokassa: shops,
|
|
}
|
|
if err := c.validate(); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// validate reports whether the configuration values are acceptable.
|
|
func (c Config) validate() error {
|
|
switch c.LogLevel {
|
|
case "debug", "info", "warn", "error":
|
|
default:
|
|
return fmt.Errorf("config: invalid BACKEND_LOG_LEVEL %q", c.LogLevel)
|
|
}
|
|
if c.HTTPAddr == "" {
|
|
return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty")
|
|
}
|
|
if c.GRPCAddr == "" {
|
|
return fmt.Errorf("config: BACKEND_GRPC_ADDR must not be empty")
|
|
}
|
|
if err := c.Postgres.Validate(); err != nil {
|
|
return fmt.Errorf("config: %w (set BACKEND_POSTGRES_DSN)", err)
|
|
}
|
|
if err := c.Telemetry.Validate(); err != nil {
|
|
return fmt.Errorf("config: %w", err)
|
|
}
|
|
if err := c.Game.Validate(); err != nil {
|
|
return fmt.Errorf("config: %w (set BACKEND_DICT_DIR)", err)
|
|
}
|
|
if err := c.Lobby.Validate(); err != nil {
|
|
return fmt.Errorf("config: %w", err)
|
|
}
|
|
if err := c.Robot.Validate(); err != nil {
|
|
return fmt.Errorf("config: %w", err)
|
|
}
|
|
if err := c.RateWatch.Validate(); err != nil {
|
|
return fmt.Errorf("config: %w", err)
|
|
}
|
|
if c.GuestReapInterval <= 0 {
|
|
return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
|
|
}
|
|
if c.GuestRetention <= 0 {
|
|
return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
|
|
}
|
|
if c.SMTP.Host != "" {
|
|
if c.PublicBaseURL == "" {
|
|
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when BACKEND_SMTP_HOST is configured")
|
|
}
|
|
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
|
|
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
|
|
}
|
|
}
|
|
for channel, shop := range c.Robokassa {
|
|
if shop.Password1 == "" || shop.Password2 == "" {
|
|
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
|
|
}
|
|
}
|
|
if !yookassa.ValidVatCode(c.YooKassaVatCode) {
|
|
return fmt.Errorf("config: BACKEND_YOOKASSA_VAT_CODE %d is not a 54-ФЗ VAT rate code (1..%d); leave it unset to send no receipts", c.YooKassaVatCode, yookassa.VatCodeMax)
|
|
}
|
|
if c.YooKassa.Configured() {
|
|
// The YooKassa rail sends the customer to a hosted payment page and needs an absolute return
|
|
// URL to bring them back, which only the public base URL can supply.
|
|
if c.PublicBaseURL == "" {
|
|
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when a YooKassa shop is configured")
|
|
}
|
|
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
|
|
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// envOr returns the value of the environment variable named key, or fallback
|
|
// when the variable is unset or empty.
|
|
func envOr(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// envInt parses the environment variable named key as an int, returning
|
|
// fallback when it is unset and an error when it is set but malformed.
|
|
func envInt(key string, fallback int) (int, error) {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return fallback, nil
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("config: %s: %w", key, err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// envDuration parses the environment variable named key as a Go duration,
|
|
// returning fallback when it is unset and an error when it is set but malformed.
|
|
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
return fallback, nil
|
|
}
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("config: %s: %w", key, err)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// yookassaShop reads a YooKassa shop's credentials from the environment under prefix (e.g.
|
|
// "BACKEND_YOOKASSA_WEB" → _SHOP_ID / _SECRET_KEY / _TEST). _TEST marks a test shop, which lets the
|
|
// intake refuse to credit a live payment against test credentials and vice versa. A shop missing
|
|
// either credential yields a Config the caller drops.
|
|
func yookassaShop(prefix string) yookassa.Config {
|
|
return yookassa.Config{
|
|
ShopID: os.Getenv(prefix + "_SHOP_ID"),
|
|
SecretKey: os.Getenv(prefix + "_SECRET_KEY"),
|
|
IsTest: os.Getenv(prefix+"_TEST") == "1",
|
|
}
|
|
}
|
|
|
|
// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g.
|
|
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
|
|
// MerchantLogin yields a zero Config the caller drops.
|
|
func robokassaShop(prefix string) robokassa.Config {
|
|
return robokassa.Config{
|
|
MerchantLogin: os.Getenv(prefix + "_MERCHANT_LOGIN"),
|
|
Password1: os.Getenv(prefix + "_PASSWORD1"),
|
|
Password2: os.Getenv(prefix + "_PASSWORD2"),
|
|
IsTest: os.Getenv(prefix+"_TEST") == "1",
|
|
}
|
|
}
|