395a307eca
CI / changes (pull_request) Successful in 11s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 29s
CI / ui (pull_request) Successful in 1m27s
CI / conformance (pull_request) Successful in 19s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
Two holes on the refund path, both found by asking what happens when a refund does not come from our own `/_gm` button. The merchant cabinet is a second entry point. An operator can refund there, and such a refund never passes through our API — so the money went back while the chips stayed credited, silently. Handle `refund.succeeded`: the refund is re-read from the API (the notification body is no more evidence here than it is for a payment), bound back to its order through the payment id recorded when the payment was minted, and reversed through the same engine. It is idempotent on (provider, refund id), so the event for a refund the console already recorded reverses nothing twice. The reversal engine is full-refund-only by design — it revokes exactly what the pack funded and rejects any other amount — so a partial refund is recorded as nothing at all and logged loudly for an operator. There is no non-arbitrary way to decide how many chips a part-refund costs, and guessing would be worse than asking a human. Second hole: a refund can still be canceled while pending, and the ledger is append-only. Recording on any non-empty refund id therefore risked revoking a customer's chips for money that stayed with us, with no way to take the row back. The console now records only a `succeeded` refund and tells the operator to press again otherwise — the idempotency key returns the same refund rather than paying twice. Tests: unit (GetRefund, the refund notification envelope, a non-final status surfaced to the caller); integration (a cabinet refund is reversed once and a redelivery is a no-op, the event after a console refund changes nothing, a partial refund records nothing, an unconfirmed refund reverses nothing, a pending refund records nothing until it settles and then does). The suite shares one database and the ledger dedupes refunds globally, so the fake provider now mints a refund id per payment — a constant id made one test's refund look like another's duplicate. Decisions D50 (amended) and D52; the notification subscription list in the deploy docs gains refund.succeeded.
167 lines
11 KiB
Bash
167 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 +
|
|
# 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=
|
|
# 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=
|