Files
scrabble-game/deploy/.env.example
T
Ilia Denisov 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
feat(payments): send no fiscal receipt; keep the code switchable
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.
2026-07-28 11:40:59 +02:00

169 lines
12 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 +
# refund.succeeded (the last one catches a refund issued in the cabinet, which bypasses our API).
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=
# Fiscal receipts (54-ФЗ) — OFF while empty, which is the intended state: the merchant runs on НПД,
# outside 54-ФЗ, so nothing is registered through the provider and each operation is reported to
# «Мой налог» instead. Set a VAT rate code (tag 1199: 1 = «Без НДС», 4 = 20%, 11 = 22%) to turn
# receipts back on — the switch a lost НПД regime would need. A Gitea VARIABLE, not a secret.
YOOKASSA_VAT_CODE=
#
# 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=