92ba527575
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Replace Robokassa with YooKassa as the RUB direct-rail provider. The wallet
model is untouched: one `direct` segment, the same spend wall, the same
per-channel merchant shops (D42) and `shop` on the order (D44).
The two providers are not shaped alike, and that drives the change:
- Opening a purchase is now an outbound API call (`POST /v3/payments`,
single-stage capture, redirect confirmation). The order id is both the
`Idempotence-Key` and `metadata.order_id`, so a retried create cannot mint a
second payment and a notification always resolves to its order.
- YooKassa does NOT sign notifications, so the body is never evidence: it only
names a payment, which is re-read with `GET /v3/payments/{id}`, and only that
answer is acted on. Two guards ride on it — the payment's metadata must name
the order, and its `test` flag must match the shop's, so a test-shop payment
can never credit real chips. The sender address is checked against YooKassa's
published ranges first, which stops a forger turning each fabricated
notification into an outbound call of ours.
- A notification lost for good would leave the money taken and the chips unowed,
silently. The existing pending-order reaper now asks the provider about each
order that reached its expiry age carrying a payment id, and credits the ones
really paid — one request per order over its whole life, not polling.
- `payment.canceled` records a `failed` event, so a declined payment is finally
surfaced to the customer as PAYMENTS.md §9 already specified.
- The admin refund moves the money through `POST /v3/refunds` before recording
anything; a failed call records nothing, so the ledger cannot claim a refund
that did not happen, and the recorded id is the provider's own.
- YooKassa has no cabinet-side generic receipt: «Чеки от ЮKassa» registers one
only if the request carries it, so every payment and refund now sends an
itemized `receipt` to the D36 confirmed email. The VAT rate code is a deploy
variable; the settlement subject and method are constants.
Robokassa is retired, not deleted: the direct rail falls back to it when no
YooKassa shop is configured and no deployment sets its credentials, so reviving
it is a credentials change rather than a code change. Its variables are removed
from compose, .env.example, write-prod-env.sh and the three workflows, and
recorded in backend/internal/robokassa/README.md together with the cabinet
configuration and the revival steps. Ledger rows keep `provider = 'robokassa'`;
that literal is load-bearing for the idempotency index.
No migration and no wire change: `orders.provider_payment_id` already existed,
and the client is rail-agnostic.
Decisions D47-D51 (revising D41) and stage E12 are baked into the docs.
166 lines
11 KiB
Bash
166 lines
11 KiB
Bash
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
|
|
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; the prod
|
|
# deploy maps the PROD_-prefixed set the same way. Values that are identical on every
|
|
# contour (DICT_VERSION, SMTP_RELAY_HOST/PORT/TLS/USER/PASS, GRAFANA_SMTP_PORT,
|
|
# VITE_VK_APP_LINK/ID, the two VK secrets) live as ONE unprefixed Gitea entry, and the
|
|
# deploy derives TELEGRAM_MINIAPP_URL / GRAFANA_ROOT_URL / VITE_VK_ID_REDIRECT_URL from
|
|
# PUBLIC_BASE_URL. Copy to deploy/.env for a local run (set the derived ones directly).
|
|
#
|
|
# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
|
|
|
|
# --- Postgres ---------------------------------------------------------------
|
|
POSTGRES_DB=scrabble
|
|
POSTGRES_USER=scrabble
|
|
POSTGRES_PASSWORD=change-me # required
|
|
|
|
# --- Point-in-time recovery (pgBackRest -> S3; PROD main host only) ----------
|
|
# Continuous WAL archiving for PITR. PROD-ONLY: the test contour never archives (these
|
|
# stay unset there and archive_mode stays off). The artifact ships DISARMED —
|
|
# PGBACKREST_ARCHIVE_MODE defaults off; arm it only after setting up the S3 repository +
|
|
# secrets and creating the stanza, then flip it on (deploy/README.md, point-in-time
|
|
# recovery). Gitea: PROD_PGBACKREST_* — endpoint/bucket/region/archive-mode are variables,
|
|
# the two S3 keys + the cipher passphrase are secrets.
|
|
PGBACKREST_ARCHIVE_MODE=off # on = archiving active (arm only after the stanza exists)
|
|
PGBACKREST_S3_ENDPOINT= # S3 HOST ONLY — no https://, no port, no bucket (e.g. s3.ru-1.storage.selcloud.ru); path-style addressing
|
|
PGBACKREST_S3_PORT= # optional; default 443 — set only if the provider uses a non-standard S3 port
|
|
PGBACKREST_S3_BUCKET= # bucket name; lowercase, no dots/underscores
|
|
PGBACKREST_S3_REGION= # e.g. ru-1
|
|
PGBACKREST_S3_KEY= # secret: S3 access key id
|
|
PGBACKREST_S3_KEY_SECRET= # secret: S3 secret access key
|
|
PGBACKREST_CIPHER_PASS= # secret: repo encryption passphrase — KEEP SAFE, stored apart from the S3 keys (losing it makes the archive unrecoverable)
|
|
|
|
# --- Dictionary -------------------------------------------------------------
|
|
# scrabble-dictionary release tag baked into the image as the SEED dictionary for a
|
|
# FRESH volume (image build-arg; also labels the resident seed version). After first
|
|
# boot the dawg-data volume preserves versions uploaded through the admin console and
|
|
# the active version lives in the DB. On a live volume a changed value is ignored (the
|
|
# recorded .seed_version marker wins — the seed-drift guard); change a running
|
|
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). One shared Gitea
|
|
# variable (DICT_VERSION) seeds both contours + pins the CI test suite.
|
|
DICT_VERSION=v1.3.1
|
|
|
|
# --- Logging ----------------------------------------------------------------
|
|
LOG_LEVEL=info
|
|
|
|
# --- Finished-game export ----------------------------------------------------
|
|
# HMAC key signing the public export download URLs (/dl/*). Required; generate a
|
|
# real contour value with `openssl rand -base64 32` (Gitea TEST_/PROD_EXPORT_SIGN_KEY).
|
|
EXPORT_SIGN_KEY=dev-export-sign-key
|
|
|
|
# --- Transactional email (Selectel relay) -----------------------------------
|
|
# Confirm-code email via the shared Selectel relay (one relay for every contour;
|
|
# limit 100 msgs / 5 min). Empty SMTP_RELAY_HOST makes the backend log codes instead
|
|
# of sending (dev). Port 465 = implicit TLS, else STARTTLS; no client cert is needed.
|
|
# TLS is implicit on port 465 and STARTTLS otherwise, unless SMTP_RELAY_TLS forces it
|
|
# (ssl|starttls) — required for Selectel's non-standard ports (1127 = SSL, 1126 =
|
|
# STARTTLS). FROM must use the prod domain (Selectel only accepts the verified sender
|
|
# domain), so it is the same on every contour; a display name is fine
|
|
# (`"Игра Эрудит" <no-reply@erudit-game.ru>`). PUBLIC_BASE_URL is the canonical https
|
|
# origin for links in the email — the contour's own public URL. Gitea
|
|
# TEST_/PROD_SMTP_RELAY_* + TEST_/PROD_PUBLIC_BASE_URL.
|
|
SMTP_RELAY_HOST=
|
|
SMTP_RELAY_PORT=465
|
|
SMTP_RELAY_TLS= # empty = auto by port; ssl | starttls to force
|
|
SMTP_RELAY_USER= # secret
|
|
SMTP_RELAY_PASS= # secret
|
|
SMTP_RELAY_FROM=no-reply@erudit-game.ru
|
|
PUBLIC_BASE_URL= # required when SMTP_RELAY_HOST is set (e.g. https://erudit-game.ru)
|
|
|
|
# Operator alerts (email). The backend emails the admin on new feedback / word complaints
|
|
# (coalesced), and Grafana emails infra alerts. Distinct senders; recipients may be several
|
|
# comma-separated addresses. Grafana reuses SMTP_RELAY_HOST/USER/PASS but dials the STARTTLS
|
|
# port (it can't do the backend's implicit TLS), GRAFANA_SMTP_PORT. All empty = off.
|
|
SMTP_RELAY_ADMIN_FROM= # backend admin-alert From (e.g. alerts@erudit-game.ru)
|
|
ADMIN_EMAIL= # backend admin-alert recipient(s), comma-separated
|
|
SMTP_RELAY_SERVICE_FROM= # Grafana alert From; the deploy derives a bare address for Grafana (it rejects "Name" <addr>)
|
|
SERVICE_EMAIL= # Grafana alert recipient(s), comma-separated
|
|
GRAFANA_SMTP_PORT= # Grafana STARTTLS port on SMTP_RELAY_HOST (Selectel: 1126)
|
|
GF_SMTP_ENABLED=false # set true to enable Grafana alert emails
|
|
|
|
# --- Edge / caddy -----------------------------------------------------------
|
|
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
|
|
# external `edge` network). Prod: a domain so caddy does its own ACME.
|
|
CADDY_SITE_ADDRESS=:80
|
|
GM_BASICAUTH_USER=gm
|
|
GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt hash
|
|
|
|
# --- UI build args (baked into the gateway image) ---------------------------
|
|
VITE_TELEGRAM_BOT_ID=
|
|
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://telegram.me/<bot>/<app>)
|
|
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
|
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
|
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
|
VITE_VK_ID_REDIRECT_URL= # VK ID trusted redirect URL (e.g. https://erudit-game.ru/app/); also the gateway's exchange redirect_uri. Deploy derives it as PUBLIC_BASE_URL + /app/
|
|
VITE_GATEWAY_URL=
|
|
|
|
# --- Grafana ----------------------------------------------------------------
|
|
GRAFANA_ROOT_URL=/_gm/grafana/ # deploy derives PUBLIC_BASE_URL + /_gm/grafana/; set the full https URL for a local run
|
|
GRAFANA_ADMIN_PASSWORD=admin
|
|
|
|
# --- Telegram validator + bot -----------------------------------------------
|
|
# The token is shared: the validator uses it as the HMAC secret, the bot for the
|
|
# Bot API. The bot-link wiring (validator/relay/mTLS addresses) is hard-wired in
|
|
# docker-compose.yml; the mTLS material is NOT here — run `deploy/gen-certs.sh`
|
|
# (writes deploy/certs/, gitignored) before `docker compose up`.
|
|
AWG_CONF= # required; AmneziaWG sidecar config (the bot's Telegram egress)
|
|
TELEGRAM_BOT_TOKEN= # required
|
|
TELEGRAM_GAME_CHANNEL_ID=
|
|
TELEGRAM_CHAT_ID= # moderated discussion chat (channel's linked group); empty disables gating
|
|
TELEGRAM_SUPPORT_CHAT_ID= # private forum supergroup for the support relay (topic per user); empty disables it
|
|
TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; empty disables it
|
|
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
|
|
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
|
|
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
|
|
TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_BASE_URL + /telegram/
|
|
TELEGRAM_TEST_ENV=false
|
|
TELEGRAM_API_BASE_URL=
|
|
|
|
# --- Client-version gate (ARCHITECTURE.md §2) -------------------------------
|
|
# The hard minimum + the soft recommended client build. Empty ⇒ dormant. Plain (unprefixed) Gitea
|
|
# variables, shared across contours; in the test contour the stamped client version is a commit hash
|
|
# (unparseable ⇒ fail-open), so these only enforce on real semver prod builds. Recommended must be ≥ min.
|
|
GATEWAY_MIN_CLIENT_VERSION=
|
|
GATEWAY_RECOMMENDED_CLIENT_VERSION=
|
|
|
|
# --- VK Mini App ------------------------------------------------------------
|
|
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
|
|
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
|
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
|
|
GATEWAY_VK_APP_SECRET=
|
|
# VK ID web login (browser VK-identity linking) — a SEPARATE VK "Web" app: the gateway runs
|
|
# the confidential OAuth 2.1 code exchange under this protected key. The app id + redirect URL
|
|
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
|
GATEWAY_VK_ID_CLIENT_SECRET=
|
|
|
|
# --- Payments: YooKassa (direct RUB rail) ------------------------------------
|
|
# One merchant shop per channel (D42): the shop id (Настройки — Магазин, field shopId) and the
|
|
# secret key (Интеграция — Ключи API). Each shop credits the one direct wallet; a shop missing
|
|
# either credential drops that channel (order routing falls back to web), and no shop at all
|
|
# leaves the direct rail off. YOOKASSA_*_TEST=1 marks a TEST shop, which makes the intake refuse
|
|
# to credit a live payment against it (and a test payment against a live shop).
|
|
# Mapped in compose to BACKEND_YOOKASSA_*; Gitea TEST_/PROD_ secrets.
|
|
# Set each shop's notification URL in its cabinet (Интеграция — HTTP-уведомления) to
|
|
# ${PUBLIC_BASE_URL}/pay/yookassa/notify, events payment.succeeded + payment.canceled.
|
|
YOOKASSA_WEB_SHOP_ID=
|
|
YOOKASSA_WEB_SECRET_KEY=
|
|
YOOKASSA_WEB_TEST=
|
|
# The android (RuStore) shop — add when that channel gets its own merchant account.
|
|
YOOKASSA_ANDROID_SHOP_ID=
|
|
YOOKASSA_ANDROID_SECRET_KEY=
|
|
YOOKASSA_ANDROID_TEST=
|
|
# VAT rate code on every fiscal receipt line (54-ФЗ tag 1199): 1 = «Без НДС» (УСН/ПСН), 4 = 20%,
|
|
# 11 = 22%. A Gitea VARIABLE, not a secret, so a rate change is a variable edit + redeploy.
|
|
YOOKASSA_VAT_CODE=1
|
|
#
|
|
# Robokassa (the retired direct rail) is deliberately absent here: no deployment sets its
|
|
# credentials, which is what keeps it dormant. The backend still reads BACKEND_ROBOKASSA_* and
|
|
# falls the direct rail back to it if they are ever set again — see
|
|
# backend/internal/robokassa/README.md for the full variable set and how to revive the rail.
|
|
|
|
# --- Gateway anti-abuse ------------------------------------------------------
|
|
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
|
# where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value
|
|
# somewhere an attacker would find it; empty disables the trap. Gitea
|
|
# TEST_/PROD_GATEWAY_HONEYTOKEN (secret).
|
|
GATEWAY_HONEYTOKEN=
|