Compare commits
67 Commits
5804f7266e
..
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 829e29a726 | |||
| 44117e906c | |||
| c90331b189 | |||
| 399508f2f0 | |||
| 0dfecc2af7 | |||
| 446ea2ac45 | |||
| 01a9249002 | |||
| 7dcd62fdd7 | |||
| d67e582c03 | |||
| 75fe07865a | |||
| 597e200f37 | |||
| 3ef18d33b6 | |||
| c8601c0115 | |||
| a0021d1994 | |||
| 4b4dcab9b6 | |||
| 7f85362288 | |||
| 72e9b600b0 | |||
| 0eefbfd6a4 | |||
| c680e695d3 | |||
| 2c465c01d2 | |||
| 60faa4f064 | |||
| 854c4b3005 | |||
| ec1bdfca00 | |||
| 70f0f9e36a | |||
| 55f6176538 | |||
| 8d2cd97e17 | |||
| f1a12c2f44 | |||
| 4cc37e5760 | |||
| 3faca690dd | |||
| c2a8426b74 | |||
| 251c7af3f6 | |||
| cabcd94d92 | |||
| aa2290b7b4 | |||
| fcde7d3db6 | |||
| d889edfdb9 | |||
| 52e6378f40 | |||
| 12af378b18 | |||
| 1598646021 | |||
| 710ab06333 | |||
| 61c7da271c | |||
| 029aa2d4cc | |||
| 3f4792a39b | |||
| 150660819a | |||
| 912096a0f1 | |||
| e4fc7f033d | |||
| b918217497 | |||
| a3eb4719de | |||
| 3a823ca7ef | |||
| 76e7916ff6 | |||
| 54af644429 | |||
| 356bf1a5ba | |||
| 1dca6741f1 | |||
| 77a18a3cc1 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
+82
-23
@@ -26,12 +26,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [development]
|
branches: [development]
|
||||||
|
|
||||||
# The dictionary release the test suite validates against — the current
|
# The dictionary release. One Gitea variable is the single source of truth: the
|
||||||
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
|
# test suite validates against it here (inherited by the unit/integration jobs) and
|
||||||
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
# both contours' deploy jobs seed a fresh volume with the same value. A release bump
|
||||||
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
# is one edit (the variable). See deploy/README.md.
|
||||||
env:
|
env:
|
||||||
DICT_VERSION: v1.3.1
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||||
@@ -329,24 +329,42 @@ jobs:
|
|||||||
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
||||||
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
||||||
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
|
# One VK Mini App serves every contour -> unprefixed secret.
|
||||||
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
|
# VK ID web login (browser VK-identity linking): the VK ID "Web" app's protected key
|
||||||
|
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
||||||
|
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
|
||||||
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
|
||||||
|
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
|
||||||
|
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
|
||||||
# Signs the finished-game export download URLs (backend + compose interpolation).
|
# Signs the finished-game export download URLs (backend + compose interpolation).
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
||||||
# Transactional email via the shared Selectel relay. Empty host leaves the
|
# Transactional email via the shared Selectel relay: one account for every
|
||||||
# backend on the log mailer (email disabled) but the contour still boots.
|
# contour -> unprefixed host/port/tls/user/pass. Empty host leaves the backend
|
||||||
SMTP_RELAY_USER: ${{ secrets.TEST_SMTP_RELAY_USER }}
|
# on the log mailer (email disabled) but the contour still boots.
|
||||||
SMTP_RELAY_PASS: ${{ secrets.TEST_SMTP_RELAY_PASS }}
|
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||||
SMTP_RELAY_HOST: ${{ vars.TEST_SMTP_RELAY_HOST }}
|
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||||
SMTP_RELAY_PORT: ${{ vars.TEST_SMTP_RELAY_PORT }}
|
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
|
||||||
SMTP_RELAY_TLS: ${{ vars.TEST_SMTP_RELAY_TLS }}
|
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
||||||
|
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
|
||||||
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
|
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
|
||||||
|
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
|
||||||
|
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
|
||||||
|
# host:port. Empty leaves the alert worker off and Grafana SMTP disabled.
|
||||||
|
SMTP_RELAY_ADMIN_FROM: ${{ vars.TEST_SMTP_RELAY_ADMIN_FROM }}
|
||||||
|
ADMIN_EMAIL: ${{ vars.TEST_ADMIN_EMAIL }}
|
||||||
|
SMTP_RELAY_SERVICE_FROM: ${{ vars.TEST_SMTP_RELAY_SERVICE_FROM }}
|
||||||
|
SERVICE_EMAIL: ${{ vars.TEST_SERVICE_EMAIL }}
|
||||||
|
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
|
||||||
|
GF_SMTP_ENABLED: ${{ vars.TEST_GF_SMTP_ENABLED }}
|
||||||
# Canonical public origin for links in the email (this contour's URL);
|
# Canonical public origin for links in the email (this contour's URL);
|
||||||
# required by the backend whenever SMTP_RELAY_HOST is set.
|
# required by the backend whenever SMTP_RELAY_HOST is set.
|
||||||
PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }}
|
PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
|
# TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL and VITE_VK_ID_REDIRECT_URL are derived
|
||||||
|
# from PUBLIC_BASE_URL in the run step below, not stored as their own variables.
|
||||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
|
||||||
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
|
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||||
@@ -359,12 +377,16 @@ jobs:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
VITE_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }}
|
# VK Mini App landing link + VK ID "Web" app id: one value each serves every
|
||||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
# contour -> unprefixed. VITE_VK_APP_ID also feeds the gateway (GATEWAY_VK_ID_APP_ID);
|
||||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
|
||||||
|
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
|
||||||
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
|
# VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the
|
||||||
|
# compose ":-" empty default. Other unset vars likewise fall to their defaults.
|
||||||
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||||
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
|
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
|
||||||
DICT_VERSION: ${{ vars.TEST_DICT_VERSION }}
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
|
||||||
run: |
|
run: |
|
||||||
# Seed the config files to a stable host path. The runner checks out into
|
# Seed the config files to a stable host path. The runner checks out into
|
||||||
@@ -375,8 +397,34 @@ jobs:
|
|||||||
conf="$HOME/.scrabble-deploy"
|
conf="$HOME/.scrabble-deploy"
|
||||||
rm -rf "$conf"
|
rm -rf "$conf"
|
||||||
mkdir -p "$conf"
|
mkdir -p "$conf"
|
||||||
cp -r caddy otelcol prometheus tempo grafana "$conf"/
|
cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
|
||||||
export SCRABBLE_CONFIG_DIR="$conf"
|
export SCRABBLE_CONFIG_DIR="$conf"
|
||||||
|
# Maintenance page for the redeploy window, mirroring prod-deploy.sh so the SPA
|
||||||
|
# overlay is exercised on the test contour too (not only prod). Raised just before
|
||||||
|
# the recreate and lowered once caddy is back (below); the trap clears it if the
|
||||||
|
# step fails so the contour never sticks in maintenance (and the reseed above wipes a
|
||||||
|
# stale flag anyway). The caddy-routed probes run in the NEXT step, after it is lowered.
|
||||||
|
maint_flag="$conf/caddy/on"
|
||||||
|
trap 'rm -f "$maint_flag"' EXIT
|
||||||
|
# Derive the public URLs from the one canonical origin instead of storing each as
|
||||||
|
# its own variable (paths are structural SPA routes / the Caddy /_gm sub-path).
|
||||||
|
# Exported before build so the VK ID redirect is baked into the SPA.
|
||||||
|
base="${PUBLIC_BASE_URL%/}"
|
||||||
|
export TELEGRAM_MINIAPP_URL="$base/telegram/"
|
||||||
|
export GRAFANA_ROOT_URL="$base/_gm/grafana/"
|
||||||
|
export VITE_VK_ID_REDIRECT_URL="$base/app/"
|
||||||
|
# Grafana's SMTP from_address must be a BARE address (it rejects the "Name" <addr>
|
||||||
|
# form the backend go-mail accepts) and validates it even when SMTP is disabled — a
|
||||||
|
# bad value crash-loops Grafana. Split the display-format SERVICE From into a bare
|
||||||
|
# address + name for Grafana; the backend keeps the full form.
|
||||||
|
svc_from="${SMTP_RELAY_SERVICE_FROM:-}"
|
||||||
|
case "$svc_from" in
|
||||||
|
*"<"*">"*)
|
||||||
|
export GRAFANA_SMTP_FROM_ADDRESS="$(printf '%s' "$svc_from" | sed -E 's/.*<([^>]+)>.*/\1/')"
|
||||||
|
export GRAFANA_SMTP_FROM_NAME="$(printf '%s' "$svc_from" | sed -E 's/[[:space:]]*<[^>]*>.*$//; s/^"//; s/"$//')" ;;
|
||||||
|
*)
|
||||||
|
export GRAFANA_SMTP_FROM_ADDRESS="$svc_from" ;;
|
||||||
|
esac
|
||||||
# Bot-link mTLS material for the test contour: a private CA + gateway/bot
|
# Bot-link mTLS material for the test contour: a private CA + gateway/bot
|
||||||
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
|
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
|
||||||
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
|
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
|
||||||
@@ -389,12 +437,23 @@ jobs:
|
|||||||
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
|
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
|
||||||
# main host omits both. Without the profile they would not start here.
|
# main host omits both. Without the profile they would not start here.
|
||||||
docker compose --ansi never --profile telegram-local build --progress plain
|
docker compose --ansi never --profile telegram-local build --progress plain
|
||||||
|
# Raise the maintenance page, THEN bring caddy onto the reseeded config mount so it
|
||||||
|
# actually carries the flag: the running caddy sits on the stale pre-reseed mount (the
|
||||||
|
# dir was rm'd + recreated — see the force-recreate note below), so a flag written to
|
||||||
|
# the new dir is invisible until caddy is recreated. With the fresh caddy up, an open
|
||||||
|
# SPA sees the 503 marker + overlay for the whole recreate window, not a bare reconnect.
|
||||||
|
: > "$maint_flag"
|
||||||
|
docker compose --ansi never up -d --force-recreate --no-deps caddy
|
||||||
docker compose --ansi never --profile telegram-local up -d --remove-orphans
|
docker compose --ansi never --profile telegram-local up -d --remove-orphans
|
||||||
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
|
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
|
||||||
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
|
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
|
||||||
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
|
# changed Grafana dashboard is ignored — force-recreate them to pick up the fresh
|
||||||
# pick up the fresh config.
|
# config. (Caddy was already recreated above so it would carry the maintenance flag.)
|
||||||
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
|
docker compose --ansi never up -d --force-recreate --no-deps otelcol prometheus tempo grafana
|
||||||
|
# Lower the maintenance page: services are back. An open SPA's poll now gets through
|
||||||
|
# (once the gateway finishes booting) and reloads into the fresh client; the caddy
|
||||||
|
# probes in the next step see 200. The EXIT trap is a backstop if we failed earlier.
|
||||||
|
rm -f "$maint_flag"
|
||||||
|
|
||||||
- name: Probe the landing, gateway and backend
|
- name: Probe the landing, gateway and backend
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -40,12 +40,22 @@ jobs:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
VITE_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }}
|
# VK Mini App link + VK ID "Web" app id: one value each serves every contour.
|
||||||
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
|
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
|
||||||
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
|
# VITE_GATEWAY_URL omitted: the SPA is served same-origin (compose ":-" default).
|
||||||
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# `docker compose build` interpolates the WHOLE compose file, so every :?-guarded
|
||||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
# runtime var must be present at build even though it is not a build-arg — incl. the
|
||||||
|
# backend's EXPORT_SIGN_KEY (added with the finished-game export after v1.7.0, which is
|
||||||
|
# why the first v1.8.0 build tripped on it). POSTGRES_PASSWORD/GM_BASICAUTH_HASH above
|
||||||
|
# are here for the same reason; DICT_VERSION + the derived Mini App URL cover the rest.
|
||||||
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
|
# PUBLIC_BASE_URL drives the derived VK ID redirect (baked into the SPA) and the
|
||||||
|
# Mini App URL; both are computed in the build step, not stored variables.
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -59,6 +69,11 @@ jobs:
|
|||||||
working-directory: deploy
|
working-directory: deploy
|
||||||
run: |
|
run: |
|
||||||
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
|
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
|
||||||
|
# Derive the public URLs from the one canonical origin: the VK ID redirect is a
|
||||||
|
# build-arg baked into the SPA, and the Mini App URL satisfies the (profiled-out)
|
||||||
|
# bot service's compose ":?" guard during parse. See deploy/write-prod-env.sh.
|
||||||
|
base="${PUBLIC_BASE_URL%/}"
|
||||||
|
export VITE_VK_ID_REDIRECT_URL="$base/app/" TELEGRAM_MINIAPP_URL="$base/telegram/"
|
||||||
# The main-stack images via compose (reuses the build args, incl. VERSION);
|
# The main-stack images via compose (reuses the build args, incl. VERSION);
|
||||||
# the bot separately, since it is profiled out of the prod compose.
|
# the bot separately, since it is profiled out of the prod compose.
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
|
||||||
@@ -83,28 +98,45 @@ jobs:
|
|||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
|
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
|
||||||
|
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
|
||||||
|
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
|
||||||
|
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
||||||
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
# Transactional email via the shared Selectel relay (confirm-codes).
|
# Transactional email via the shared Selectel relay (confirm-codes): one account for
|
||||||
SMTP_RELAY_USER: ${{ secrets.PROD_SMTP_RELAY_USER }}
|
# every contour -> unprefixed host/port/tls/user/pass.
|
||||||
SMTP_RELAY_PASS: ${{ secrets.PROD_SMTP_RELAY_PASS }}
|
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||||
SMTP_RELAY_HOST: ${{ vars.PROD_SMTP_RELAY_HOST }}
|
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||||
SMTP_RELAY_PORT: ${{ vars.PROD_SMTP_RELAY_PORT }}
|
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
|
||||||
SMTP_RELAY_TLS: ${{ vars.PROD_SMTP_RELAY_TLS }}
|
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
||||||
|
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
|
||||||
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
|
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
|
||||||
|
# Operator alerts: backend admin emails + Grafana infra alerts (distinct senders +
|
||||||
|
# recipients; Grafana uses the relay's STARTTLS host:port).
|
||||||
|
SMTP_RELAY_ADMIN_FROM: ${{ vars.PROD_SMTP_RELAY_ADMIN_FROM }}
|
||||||
|
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
|
||||||
|
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
|
||||||
|
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
|
||||||
|
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
|
||||||
|
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
|
||||||
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
||||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
|
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
||||||
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# TELEGRAM_MINIAPP_URL and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in
|
||||||
|
# deploy/write-prod-env.sh, not stored variables.
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -132,33 +164,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-main
|
mkdir -p stage/certs-main
|
||||||
cat > stage/env.sh <<EOF
|
# Shared with prod-rollback so the two paths render an identical runtime env.
|
||||||
export REGISTRY='$REGISTRY'
|
APP_VERSION="$TAG" bash deploy/write-prod-env.sh stage/env.sh
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
|
||||||
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
|
|
||||||
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
|
|
||||||
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
|
|
||||||
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
|
|
||||||
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
|
|
||||||
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
|
|
||||||
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
|
|
||||||
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
|
|
||||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
|
||||||
export DICT_VERSION='$DICT_VERSION'
|
|
||||||
export APP_VERSION='$TAG'
|
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
|
||||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
|
||||||
export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY'
|
|
||||||
export SMTP_RELAY_HOST='$SMTP_RELAY_HOST'
|
|
||||||
export SMTP_RELAY_PORT='$SMTP_RELAY_PORT'
|
|
||||||
export SMTP_RELAY_TLS='$SMTP_RELAY_TLS'
|
|
||||||
export SMTP_RELAY_USER='$SMTP_RELAY_USER'
|
|
||||||
export SMTP_RELAY_PASS='$SMTP_RELAY_PASS'
|
|
||||||
export SMTP_RELAY_FROM='$SMTP_RELAY_FROM'
|
|
||||||
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
|
|
||||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
||||||
@@ -169,7 +176,7 @@ jobs:
|
|||||||
ssh_main 'mkdir -p /opt/scrabble/compose'
|
ssh_main 'mkdir -p /opt/scrabble/compose'
|
||||||
tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \
|
tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \
|
||||||
| ssh_main 'tar -C /opt/scrabble/compose -xzf -'
|
| ssh_main 'tar -C /opt/scrabble/compose -xzf -'
|
||||||
tar -C deploy -czf - caddy otelcol prometheus tempo grafana \
|
tar -C deploy -czf - caddy otelcol prometheus tempo grafana blackbox \
|
||||||
| ssh_main 'tar -C /opt/scrabble -xzf -'
|
| ssh_main 'tar -C /opt/scrabble -xzf -'
|
||||||
tar -C stage -czf - certs-main \
|
tar -C stage -czf - certs-main \
|
||||||
| ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
|
| ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
|
||||||
@@ -197,7 +204,8 @@ jobs:
|
|||||||
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
||||||
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# PUBLIC_BASE_URL drives the derived Mini App URL in deploy/write-prod-bot-env.sh.
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
||||||
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
|
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||||
@@ -214,20 +222,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-bot
|
mkdir -p stage/certs-bot
|
||||||
cat > stage/env.bot.sh <<EOF
|
# Shared with prod-rollback so the two paths render an identical bot env.
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TAG" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
|
||||||
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TAG'
|
|
||||||
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
|
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
|
||||||
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
|
|
||||||
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
|
|
||||||
export TELEGRAM_SUPPORT_CHAT_ID='$TELEGRAM_SUPPORT_CHAT_ID'
|
|
||||||
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
|
|
||||||
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
|
|
||||||
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
|
|
||||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
|
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
||||||
|
|||||||
@@ -51,13 +51,32 @@ jobs:
|
|||||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
|
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
||||||
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
|
# Full runtime env — parity with prod-deploy's deploy-main so a rollback re-renders
|
||||||
|
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
|
||||||
|
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
|
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||||
|
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||||
|
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
|
||||||
|
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
||||||
|
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
|
||||||
|
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
|
||||||
|
SMTP_RELAY_ADMIN_FROM: ${{ vars.PROD_SMTP_RELAY_ADMIN_FROM }}
|
||||||
|
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
|
||||||
|
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
|
||||||
|
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
|
||||||
|
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
|
||||||
|
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
|
||||||
INPUT_TARGET: ${{ inputs.target_version }}
|
INPUT_TARGET: ${{ inputs.target_version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -89,24 +108,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-main
|
mkdir -p stage/certs-main
|
||||||
cat > stage/env.sh <<EOF
|
# Same writer as prod-deploy's deploy-main -> the rollback re-renders the FULL
|
||||||
export REGISTRY='$REGISTRY'
|
# runtime env (not a subset), so email / VK login / Grafana alerts survive it.
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
APP_VERSION="$TARGET" bash deploy/write-prod-env.sh stage/env.sh
|
||||||
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
|
|
||||||
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
|
|
||||||
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
|
|
||||||
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
|
|
||||||
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
|
|
||||||
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
|
|
||||||
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
|
|
||||||
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
|
|
||||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
|
||||||
export DICT_VERSION='$DICT_VERSION'
|
|
||||||
export APP_VERSION='$TARGET'
|
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
|
||||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
||||||
@@ -147,9 +151,11 @@ jobs:
|
|||||||
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
||||||
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# PUBLIC_BASE_URL drives the derived Mini App URL; SUPPORT_CHAT_ID for parity with deploy.
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
||||||
|
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||||
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
|
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
|
||||||
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||||
steps:
|
steps:
|
||||||
@@ -163,19 +169,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-bot
|
mkdir -p stage/certs-bot
|
||||||
cat > stage/env.bot.sh <<EOF
|
# Same writer as prod-deploy's deploy-bot (parity; includes TELEGRAM_SUPPORT_CHAT_ID).
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TARGET" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
|
||||||
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TARGET'
|
|
||||||
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
|
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
|
||||||
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
|
|
||||||
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
|
|
||||||
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
|
|
||||||
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
|
|
||||||
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
|
|
||||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
|
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/accountmerge"
|
"scrabble/backend/internal/accountmerge"
|
||||||
|
"scrabble/backend/internal/adminalert"
|
||||||
"scrabble/backend/internal/ads"
|
"scrabble/backend/internal/ads"
|
||||||
"scrabble/backend/internal/banview"
|
"scrabble/backend/internal/banview"
|
||||||
"scrabble/backend/internal/config"
|
"scrabble/backend/internal/config"
|
||||||
@@ -44,6 +46,10 @@ import (
|
|||||||
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
|
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
|
||||||
const telemetryShutdownTimeout = 5 * time.Second
|
const telemetryShutdownTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// adminAlertInterval is how often the operator-alert worker checks for new feedback /
|
||||||
|
// complaints; a burst within one interval coalesces into a single digest email.
|
||||||
|
const adminAlertInterval = 5 * time.Minute
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -162,6 +168,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
zap.Duration("interval", cfg.GuestReapInterval),
|
zap.Duration("interval", cfg.GuestReapInterval),
|
||||||
zap.Duration("retention", cfg.GuestRetention))
|
zap.Duration("retention", cfg.GuestRetention))
|
||||||
|
|
||||||
|
// Purge the account-deletion legal dossier past its retention TTL: the
|
||||||
|
// retained-identities journal, and the feedback thread + dossier PII of long-deleted
|
||||||
|
// accounts (chat is kept). Checked daily; the TTL is a two-year policy constant.
|
||||||
|
retentionReaper := account.NewRetentionReaper(accounts, account.RetentionTTL, logger)
|
||||||
|
go retentionReaper.Run(ctx, 24*time.Hour)
|
||||||
|
logger.Info("retention reaper started",
|
||||||
|
zap.Duration("interval", 24*time.Hour),
|
||||||
|
zap.Duration("retention", account.RetentionTTL))
|
||||||
|
|
||||||
// Re-evaluate moderated-chat write access when a temporary block self-expires:
|
// Re-evaluate moderated-chat write access when a temporary block self-expires:
|
||||||
// no operator action fires then, so the sweeper emits the chat-access-changed
|
// no operator action fires then, so the sweeper emits the chat-access-changed
|
||||||
// event for lapsed blocks and the gateway re-pushes the chat-gate command.
|
// event for lapsed blocks and the gateway re-pushes the chat-gate command.
|
||||||
@@ -198,6 +213,18 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
|
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
|
||||||
feedbackSvc.SetNotifier(hub)
|
feedbackSvc.SetNotifier(hub)
|
||||||
|
|
||||||
|
// Operator alert emails on new feedback / word complaints, coalesced into one digest
|
||||||
|
// per interval. Inert unless a distinct admin sender and recipient are configured.
|
||||||
|
if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" {
|
||||||
|
consoleURL := ""
|
||||||
|
if cfg.PublicBaseURL != "" {
|
||||||
|
consoleURL = strings.TrimRight(cfg.PublicBaseURL, "/") + "/_gm"
|
||||||
|
}
|
||||||
|
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, consoleURL, logger)
|
||||||
|
go alerts.Run(ctx, adminAlertInterval)
|
||||||
|
logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval))
|
||||||
|
}
|
||||||
|
|
||||||
// Robot opponent: provision its durable account pool (a hard startup
|
// Robot opponent: provision its durable account pool (a hard startup
|
||||||
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
||||||
// substitutes a pooled robot for a missing human after the wait window.
|
// substitutes a pooled robot for a missing human after the wait window.
|
||||||
|
|||||||
@@ -35,12 +35,14 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Confirmation purposes recorded on a pending confirm-code row. They select what
|
// Confirmation purposes recorded on a pending confirm-code row. They select what
|
||||||
// verifying the code or the deeplink token does: sign in (login) or link/confirm the
|
// verifying the code or the deeplink token does: sign in (login), link/confirm the
|
||||||
// address on the current account (link). Email change and account deletion add
|
// address on the current account (link), or replace the account's confirmed email with
|
||||||
// further purposes in later stages.
|
// a new address (change). Account deletion adds a further purpose in a later stage.
|
||||||
const (
|
const (
|
||||||
purposeLogin = "login"
|
purposeLogin = "login"
|
||||||
purposeLink = "link"
|
purposeLink = "link"
|
||||||
|
purposeChange = "change"
|
||||||
|
purposeDelete = "delete"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Errors returned by the email confirm-code flow.
|
// Errors returned by the email confirm-code flow.
|
||||||
@@ -64,6 +66,9 @@ var (
|
|||||||
// ErrTooManyRequests is returned when confirm-code sends to an address are being
|
// ErrTooManyRequests is returned when confirm-code sends to an address are being
|
||||||
// requested too frequently (the resend cooldown or the rolling-hour cap).
|
// requested too frequently (the resend cooldown or the rolling-hour cap).
|
||||||
ErrTooManyRequests = errors.New("account: too many code requests")
|
ErrTooManyRequests = errors.New("account: too many code requests")
|
||||||
|
// ErrNoEmail is returned when an email-code step-up is requested for an account that
|
||||||
|
// holds no confirmed email (the caller must use the typed-phrase path instead).
|
||||||
|
ErrNoEmail = errors.New("account: no confirmed email")
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
|
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
|
||||||
@@ -114,7 +119,14 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
|
|||||||
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
|
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token), s.baseURL, locale)
|
// Account deletion is never a one-tap link (a prefetch or a stray click must not delete
|
||||||
|
// an account): the delete code is entered in the app only, so the email omits the
|
||||||
|
// deeplink and ConfirmByToken refuses a delete token.
|
||||||
|
deeplink := s.confirmURL(token, locale)
|
||||||
|
if purpose == purposeDelete {
|
||||||
|
deeplink = ""
|
||||||
|
}
|
||||||
|
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -122,13 +134,14 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
|
|||||||
return s.mailer.Send(ctx, msg)
|
return s.mailer.Send(ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// confirmURL builds the absolute one-tap confirm deeplink for token, or "" when no
|
// confirmURL builds the absolute one-tap confirm deeplink for token in locale, or ""
|
||||||
// public base URL is configured.
|
// when no public base URL is configured. The locale rides the fragment as ?lang so the
|
||||||
func (s *EmailService) confirmURL(token string) string {
|
// confirm screen (opened in a browser with no session) renders in the email's language.
|
||||||
|
func (s *EmailService) confirmURL(token, locale string) string {
|
||||||
if s.baseURL == "" {
|
if s.baseURL == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token
|
return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
// accountLocale returns the account's preferred UI language for localising email,
|
// accountLocale returns the account's preferred UI language for localising email,
|
||||||
@@ -193,6 +206,11 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
|
|||||||
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
|
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
|
||||||
return Account{}, err
|
return Account{}, err
|
||||||
}
|
}
|
||||||
|
// Binding the first confirmed email promotes a guest to a durable account, matching the
|
||||||
|
// link and deeplink flows (defence-in-depth: no confirmed-email path leaves is_guest set).
|
||||||
|
if err := s.store.ClearGuest(ctx, accountID); err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
return s.store.GetByID(ctx, accountID)
|
return s.store.GetByID(ctx, accountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +337,35 @@ func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkCo
|
|||||||
if err := s.store.confirmEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
|
if err := s.store.confirmEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
|
||||||
return LinkConfirmation{}, err
|
return LinkConfirmation{}, err
|
||||||
}
|
}
|
||||||
|
// Binding the first email promotes a guest to a durable account, matching the
|
||||||
|
// code-based link flow (which clears the guest flag in the link service).
|
||||||
|
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
|
||||||
|
return LinkConfirmation{}, err
|
||||||
|
}
|
||||||
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
|
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
|
||||||
|
case purposeChange:
|
||||||
|
owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
|
||||||
|
if err != nil {
|
||||||
|
return LinkConfirmation{}, err
|
||||||
|
}
|
||||||
|
if ok && owner != pend.accountID {
|
||||||
|
// The new address is confirmed by a different account: refuse without
|
||||||
|
// disclosing it (anti-enumeration). Unlike a link, a change never merges.
|
||||||
|
return LinkConfirmation{}, ErrEmailTaken
|
||||||
|
}
|
||||||
|
if ok && owner == pend.accountID {
|
||||||
|
if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
|
||||||
|
return LinkConfirmation{}, err
|
||||||
|
}
|
||||||
|
return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
|
||||||
|
}
|
||||||
|
if err := s.store.replaceEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
|
||||||
|
return LinkConfirmation{}, err
|
||||||
|
}
|
||||||
|
return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
|
||||||
|
case purposeDelete:
|
||||||
|
// Deletion is confirmed in the app with the code, never via a one-tap link.
|
||||||
|
return LinkConfirmation{}, fmt.Errorf("account: deletion cannot be confirmed by link")
|
||||||
default:
|
default:
|
||||||
return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
|
return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
|
||||||
}
|
}
|
||||||
@@ -353,6 +399,27 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U
|
|||||||
return row.AccountID, true, nil
|
return row.AccountID, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// confirmedEmailOf returns the account's confirmed email address and true, or ("", false)
|
||||||
|
// when it holds none. It backs the deletion step-up, which mails a code to the account's
|
||||||
|
// own address.
|
||||||
|
func (s *Store) confirmedEmailOf(ctx context.Context, accountID uuid.UUID) (string, bool, error) {
|
||||||
|
stmt := postgres.SELECT(table.Identities.ExternalID).
|
||||||
|
FROM(table.Identities).
|
||||||
|
WHERE(
|
||||||
|
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
|
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))).
|
||||||
|
AND(table.Identities.Confirmed.EQ(postgres.Bool(true))),
|
||||||
|
).LIMIT(1)
|
||||||
|
var row model.Identities
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return "", false, fmt.Errorf("account: confirmed email of %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return row.ExternalID, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// replacePendingConfirmation clears any pending code for (accountID, email) and
|
// replacePendingConfirmation clears any pending code for (accountID, email) and
|
||||||
// inserts a fresh one, inside one transaction.
|
// inserts a fresh one, inside one transaction.
|
||||||
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error {
|
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error {
|
||||||
@@ -500,6 +567,69 @@ func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accoun
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// replaceEmailIdentity consumes the confirmation, deletes the account's existing email
|
||||||
|
// identity (freeing the old address) and inserts newEmail as its confirmed email, inside
|
||||||
|
// one transaction. It backs the change-email flow. A unique-constraint violation — the
|
||||||
|
// new address was confirmed elsewhere in the meantime — surfaces as ErrEmailTaken. When
|
||||||
|
// the account holds no email identity yet the delete is a no-op, so this doubles as an
|
||||||
|
// attach.
|
||||||
|
func (s *Store) replaceEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, newEmail string, now time.Time) error {
|
||||||
|
identityID, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("account: new identity id: %w", err)
|
||||||
|
}
|
||||||
|
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
upd := table.EmailConfirmations.
|
||||||
|
UPDATE(table.EmailConfirmations.ConsumedAt).
|
||||||
|
SET(postgres.TimestampzT(now)).
|
||||||
|
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
|
||||||
|
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("consume confirmation: %w", err)
|
||||||
|
}
|
||||||
|
// Journal the outgoing email before replacing it, so the legal dossier keeps the
|
||||||
|
// address the account used to hold (see retention.go).
|
||||||
|
var old model.Identities
|
||||||
|
sel := postgres.SELECT(
|
||||||
|
table.Identities.ExternalID, table.Identities.Confirmed, table.Identities.CreatedAt,
|
||||||
|
).FROM(table.Identities).WHERE(
|
||||||
|
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
|
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
|
||||||
|
).LIMIT(1)
|
||||||
|
switch err := sel.QueryContext(ctx, tx, &old); {
|
||||||
|
case err == nil:
|
||||||
|
if err := retainIdentityTx(ctx, tx, accountID, KindEmail, old.ExternalID, old.Confirmed, old.CreatedAt, retainChange); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case errors.Is(err, qrm.ErrNoRows):
|
||||||
|
// No prior email (this doubles as an attach); nothing to retain.
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("load outgoing email identity: %w", err)
|
||||||
|
}
|
||||||
|
del := table.Identities.DELETE().WHERE(
|
||||||
|
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
|
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
|
||||||
|
)
|
||||||
|
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("delete old email identity: %w", err)
|
||||||
|
}
|
||||||
|
ins := table.Identities.INSERT(
|
||||||
|
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
|
||||||
|
table.Identities.ExternalID, table.Identities.Confirmed,
|
||||||
|
).VALUES(identityID, accountID, KindEmail, newEmail, true)
|
||||||
|
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return ErrEmailTaken
|
||||||
|
}
|
||||||
|
return fmt.Errorf("account: replace email identity: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// confirmEmailLogin consumes the login code and marks the existing email
|
// confirmEmailLogin consumes the login code and marks the existing email
|
||||||
// identity confirmed, inside one transaction. The identity already exists (a
|
// identity confirmed, inside one transaction. The identity already exists (a
|
||||||
// login provisioned it), so this updates rather than inserts and is idempotent
|
// login provisioned it), so this updates rather than inserts and is idempotent
|
||||||
|
|||||||
@@ -80,6 +80,42 @@ var confirmEmailCopy = map[string]map[string]emailCopy{
|
|||||||
FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
|
FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
purposeChange: {
|
||||||
|
"en": {
|
||||||
|
Subject: "Confirm your new Erudit e-mail",
|
||||||
|
Preheader: "Confirm your new address",
|
||||||
|
Heading: "Confirm your new e-mail",
|
||||||
|
Intro: "Enter this code to switch your account to this address:",
|
||||||
|
CTALabel: "Confirm with one tap",
|
||||||
|
FooterIgnore: "If you didn't request this change, you can safely ignore it — your address stays the same.",
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
Subject: "Подтвердите новый e-mail в Эрудит",
|
||||||
|
Preheader: "Подтвердите новый адрес",
|
||||||
|
Heading: "Смена e-mail",
|
||||||
|
Intro: "Введите этот код, чтобы привязать аккаунт к новому адресу:",
|
||||||
|
CTALabel: "Подтвердить одним нажатием",
|
||||||
|
FooterIgnore: "Если вы не запрашивали смену, просто проигнорируйте письмо — адрес останется прежним.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
purposeDelete: {
|
||||||
|
"en": {
|
||||||
|
Subject: "Confirm your Erudit account deletion",
|
||||||
|
Preheader: "Confirm account deletion",
|
||||||
|
Heading: "Delete your account",
|
||||||
|
Intro: "Enter this code in the app to permanently delete your account:",
|
||||||
|
CTALabel: "",
|
||||||
|
FooterIgnore: "If you didn't request this, ignore it — your account stays as it is.",
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
Subject: "Подтвердите удаление аккаунта Эрудит",
|
||||||
|
Preheader: "Подтверждение удаления аккаунта",
|
||||||
|
Heading: "Удаление аккаунта",
|
||||||
|
Intro: "Введите этот код в приложении, чтобы удалить аккаунт без восстановления:",
|
||||||
|
CTALabel: "",
|
||||||
|
FooterIgnore: "Если вы не запрашивали удаление, проигнорируйте письмо — аккаунт останется.",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// emailBrand is the brand wordmark per locale.
|
// emailBrand is the brand wordmark per locale.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ func TestRenderConfirmationEmail(t *testing.T) {
|
|||||||
{"login en", purposeLogin, "en", "sign-in"},
|
{"login en", purposeLogin, "en", "sign-in"},
|
||||||
{"link ru", purposeLink, "ru", "подтвержд"},
|
{"link ru", purposeLink, "ru", "подтвержд"},
|
||||||
{"link en", purposeLink, "en", "confirmation"},
|
{"link en", purposeLink, "en", "confirmation"},
|
||||||
|
{"change ru", purposeChange, "ru", "новый"},
|
||||||
|
{"change en", purposeChange, "en", "new"},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package account
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,6 +17,68 @@ import (
|
|||||||
// belongs to another account; the caller turns it into a merge.
|
// belongs to another account; the caller turns it into a merge.
|
||||||
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
||||||
|
|
||||||
|
// ErrLastIdentity is returned when removing an identity would leave the account with
|
||||||
|
// none, making it unreachable after logout. The admin email-erase refuses it.
|
||||||
|
var ErrLastIdentity = errors.New("account: cannot remove the last identity")
|
||||||
|
|
||||||
|
// RemoveIdentity deletes the account's identity of the given kind (and, for an email,
|
||||||
|
// any pending confirmations for it), freeing it for reuse. It refuses when that is the
|
||||||
|
// account's only identity (ErrLastIdentity) — which would leave the account
|
||||||
|
// unreachable — and returns ErrNotFound when the account has no identity of that kind.
|
||||||
|
// It backs the profile Unlink control and the admin "erase email" action.
|
||||||
|
func (s *Store) RemoveIdentity(ctx context.Context, accountID uuid.UUID, kind string) error {
|
||||||
|
ids, err := s.Identities(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var toRetain []Identity
|
||||||
|
others := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
if id.Kind == kind {
|
||||||
|
toRetain = append(toRetain, id)
|
||||||
|
} else {
|
||||||
|
others++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(toRetain) == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if others == 0 {
|
||||||
|
return ErrLastIdentity
|
||||||
|
}
|
||||||
|
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
// Journal the detached credential before removing it, so the legal dossier
|
||||||
|
// survives while the identity frees for reuse (see retention.go).
|
||||||
|
for _, id := range toRetain {
|
||||||
|
if err := retainIdentityTx(ctx, tx, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainUnlink); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delID := table.Identities.DELETE().WHERE(
|
||||||
|
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
|
AND(table.Identities.Kind.EQ(postgres.String(kind))),
|
||||||
|
)
|
||||||
|
if _, err := delID.ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("account: delete %s identity %s: %w", kind, accountID, err)
|
||||||
|
}
|
||||||
|
if kind == KindEmail {
|
||||||
|
delConf := table.EmailConfirmations.DELETE().WHERE(
|
||||||
|
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)),
|
||||||
|
)
|
||||||
|
if _, err := delConf.ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("account: delete email confirmations %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveEmailIdentity erases the account's email identity. It backs the admin console's
|
||||||
|
// "erase email" action; the user-facing profile never unlinks email (it is changed).
|
||||||
|
func (s *Store) RemoveEmailIdentity(ctx context.Context, accountID uuid.UUID) error {
|
||||||
|
return s.RemoveIdentity(ctx, accountID, KindEmail)
|
||||||
|
}
|
||||||
|
|
||||||
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
||||||
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
||||||
// (taken or already-confirmed): possession of the address is the authorization for
|
// (taken or already-confirmed): possession of the address is the authorization for
|
||||||
@@ -64,6 +127,100 @@ func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, ema
|
|||||||
return accountID, true, nil
|
return accountID, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequestChangeCode issues and mails a confirm-code to newEmail for an authenticated
|
||||||
|
// email change on accountID, replacing any prior pending code. Like RequestLinkCode it
|
||||||
|
// never refuses up front on "taken" (anti-enumeration): possession of newEmail is the
|
||||||
|
// authorization, and a conflict with another account is revealed only at confirm — as a
|
||||||
|
// non-disclosing refusal, never a merge.
|
||||||
|
func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUID, newEmail string) error {
|
||||||
|
addr, err := normalizeEmail(newEmail)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !s.allowSend(addr) {
|
||||||
|
return ErrTooManyRequests
|
||||||
|
}
|
||||||
|
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the
|
||||||
|
// account's confirmed email with newEmail, freeing the old address. When newEmail is
|
||||||
|
// already confirmed by another account it refuses with ErrEmailTaken (surfaced to the
|
||||||
|
// user as a non-disclosing "check the address or contact support"), never merging; when
|
||||||
|
// the account already owns newEmail it is an idempotent no-op. It returns the usual
|
||||||
|
// confirm-code errors (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts,
|
||||||
|
// ErrCodeMismatch) and the updated account on success.
|
||||||
|
func (s *EmailService) ConfirmChange(ctx context.Context, accountID uuid.UUID, newEmail, code string) (Account, error) {
|
||||||
|
addr, err := normalizeEmail(newEmail)
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
|
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
|
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
|
if ok && owner != accountID {
|
||||||
|
return Account{}, ErrEmailTaken
|
||||||
|
}
|
||||||
|
if ok && owner == accountID {
|
||||||
|
if err := s.store.consumeConfirmation(ctx, conf.id, s.now()); err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
|
return s.store.GetByID(ctx, accountID)
|
||||||
|
}
|
||||||
|
if err := s.store.replaceEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
|
return s.store.GetByID(ctx, accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasEmail reports whether accountID owns a confirmed email. The account-deletion step-up
|
||||||
|
// mails a confirm-code when it does, and falls back to a typed phrase otherwise.
|
||||||
|
func (s *EmailService) HasEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||||
|
_, ok, err := s.store.confirmedEmailOf(ctx, accountID)
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestDeleteCode mails an account-deletion confirm-code to the account's own confirmed
|
||||||
|
// email (no deeplink — deletion is confirmed in the app). It returns ErrNoEmail when the
|
||||||
|
// account holds no email, ErrTooManyRequests when throttled.
|
||||||
|
func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUID) error {
|
||||||
|
addr, ok, err := s.store.confirmedEmailOf(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return ErrNoEmail
|
||||||
|
}
|
||||||
|
if !s.allowSend(addr) {
|
||||||
|
return ErrTooManyRequests
|
||||||
|
}
|
||||||
|
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyDeleteCode verifies the account-deletion code against the account's own email and
|
||||||
|
// consumes it on success. It returns ErrNoEmail (no email), the usual confirm-code errors
|
||||||
|
// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch), or nil when the
|
||||||
|
// code is valid — the caller then performs the deletion.
|
||||||
|
func (s *EmailService) VerifyDeleteCode(ctx context.Context, accountID uuid.UUID, code string) error {
|
||||||
|
addr, ok, err := s.store.confirmedEmailOf(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return ErrNoEmail
|
||||||
|
}
|
||||||
|
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.store.consumeConfirmation(ctx, conf.id, s.now())
|
||||||
|
}
|
||||||
|
|
||||||
// verifyPendingCode loads and checks the pending confirm-code for (accountID,
|
// verifyPendingCode loads and checks the pending confirm-code for (accountID,
|
||||||
// addr), counting a wrong attempt. It returns the confirmation on success.
|
// addr), counting a wrong attempt. It returns the confirmation on success.
|
||||||
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
|
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import (
|
|||||||
// required plain-text body and doubles as the multipart/alternative fallback;
|
// required plain-text body and doubles as the multipart/alternative fallback;
|
||||||
// HTML, when non-empty, is the preferred body a capable client renders instead.
|
// HTML, when non-empty, is the preferred body a capable client renders instead.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
|
// To is the recipient address, or several comma-separated (all get the one message).
|
||||||
To string
|
To string
|
||||||
|
// From, when non-empty, overrides the configured sender for this message — the admin
|
||||||
|
// alert path uses a distinct From from the user-facing confirm-code sender.
|
||||||
|
From string
|
||||||
Subject string
|
Subject string
|
||||||
Text string
|
Text string
|
||||||
HTML string
|
HTML string
|
||||||
@@ -29,6 +33,18 @@ type Mailer interface {
|
|||||||
Send(ctx context.Context, msg Message) error
|
Send(ctx context.Context, msg Message) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitAddrs splits a comma-separated recipient list into trimmed, non-empty addresses.
|
||||||
|
func splitAddrs(list string) []string {
|
||||||
|
parts := strings.Split(list, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if a := strings.TrimSpace(p); a != "" {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer
|
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer
|
||||||
// instead, so a deployment without a relay still runs (the code lands in the log).
|
// instead, so a deployment without a relay still runs (the code lands in the log).
|
||||||
// TLS is always used and no client certificate is required — only the server
|
// TLS is always used and no client certificate is required — only the server
|
||||||
@@ -44,6 +60,11 @@ type SMTPConfig struct {
|
|||||||
// port (implicit TLS on 465, STARTTLS otherwise); set it explicitly for a relay on
|
// port (implicit TLS on 465, STARTTLS otherwise); set it explicitly for a relay on
|
||||||
// a non-standard port (e.g. Selectel's 1127 = SSL, 1126 = STARTTLS).
|
// a non-standard port (e.g. Selectel's 1127 = SSL, 1126 = STARTTLS).
|
||||||
TLS string
|
TLS string
|
||||||
|
// AdminFrom / AdminTo drive the operator alert emails (new feedback / word complaints),
|
||||||
|
// distinct from the user-facing confirm-code sender. AdminTo may be several
|
||||||
|
// comma-separated addresses. Both empty disables the alert worker.
|
||||||
|
AdminFrom string
|
||||||
|
AdminTo string
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -110,10 +131,16 @@ func (m SMTPMailer) Send(ctx context.Context, msg Message) error {
|
|||||||
return fmt.Errorf("account: build mail client: %w", err)
|
return fmt.Errorf("account: build mail client: %w", err)
|
||||||
}
|
}
|
||||||
out := mail.NewMsg()
|
out := mail.NewMsg()
|
||||||
if err := out.From(m.cfg.From); err != nil {
|
from := m.cfg.From
|
||||||
return fmt.Errorf("account: set From %q: %w", m.cfg.From, err)
|
if msg.From != "" {
|
||||||
|
from = msg.From
|
||||||
}
|
}
|
||||||
if err := out.To(msg.To); err != nil {
|
if err := out.From(from); err != nil {
|
||||||
|
return fmt.Errorf("account: set From %q: %w", from, err)
|
||||||
|
}
|
||||||
|
// To may carry several comma-separated recipients; go-mail wants them as separate
|
||||||
|
// arguments (a single joined string parses as one malformed address).
|
||||||
|
if err := out.To(splitAddrs(msg.To)...); err != nil {
|
||||||
return fmt.Errorf("account: set To %q: %w", msg.To, err)
|
return fmt.Errorf("account: set To %q: %w", msg.To, err)
|
||||||
}
|
}
|
||||||
out.Subject(msg.Subject)
|
out.Subject(msg.Subject)
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
package account
|
package account
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSplitAddrs covers the comma-separated recipient parsing used for the admin alert
|
||||||
|
// To (several operator mailboxes in one message), including trimming and empty entries.
|
||||||
|
func TestSplitAddrs(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"a@x.ru", []string{"a@x.ru"}},
|
||||||
|
{"a@x.ru, b@y.ru", []string{"a@x.ru", "b@y.ru"}},
|
||||||
|
{" a@x.ru ,, b@y.ru ,", []string{"a@x.ru", "b@y.ru"}},
|
||||||
|
{"", nil},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := splitAddrs(c.in); !slices.Equal(got, c.want) {
|
||||||
|
t.Errorf("splitAddrs(%q) = %v, want %v", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSMTPTLSMode covers the explicit TLS mode and the port-based fallback, including
|
// TestSMTPTLSMode covers the explicit TLS mode and the port-based fallback, including
|
||||||
// the non-standard Selectel ports (1127 = SSL, 1126 = STARTTLS) that the 465 heuristic
|
// the non-standard Selectel ports (1127 = SSL, 1126 = STARTTLS) that the 465 heuristic
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RetentionTTL bounds how long the account-deletion legal dossier is kept before the
|
||||||
|
// reaper purges it: two years from the detach/deletion event (owner policy, 2026-07-03).
|
||||||
|
const RetentionTTL = 2 * 365 * 24 * time.Hour
|
||||||
|
|
||||||
|
// Reasons recorded on a retained_identities row: what detached the credential from its
|
||||||
|
// account (unlink / email change / account deletion here; an account merge that drops a
|
||||||
|
// same-kind colliding identity writes reason "merge" from the accountmerge package). The
|
||||||
|
// row is written just before the live identities row is removed, preserving the legal
|
||||||
|
// dossier (which email/vk/tg was linked, and when) even as the identity frees for reuse.
|
||||||
|
// See docs/ARCHITECTURE.md §9.1.
|
||||||
|
const (
|
||||||
|
retainUnlink = "unlink"
|
||||||
|
retainChange = "change"
|
||||||
|
retainDelete = "delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
// retainIdentityTx appends a retention-journal row for one identity being detached, inside
|
||||||
|
// tx. linkedAt is the identity's original creation time; detached_at defaults to now(). It
|
||||||
|
// must run in the same transaction as the identity removal, so the dossier and the live
|
||||||
|
// state can never diverge.
|
||||||
|
func retainIdentityTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind, externalID string, confirmed bool, linkedAt time.Time, reason string) error {
|
||||||
|
id, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("account: new retained id: %w", err)
|
||||||
|
}
|
||||||
|
ins := table.RetainedIdentities.INSERT(
|
||||||
|
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
|
||||||
|
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
|
||||||
|
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
|
||||||
|
table.RetainedIdentities.Reason,
|
||||||
|
).VALUES(id, accountID, kind, externalID, confirmed, linkedAt, reason)
|
||||||
|
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("account: retain identity (%s, %s): %w", kind, externalID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StampLastLogin records the account's last cold-load time and client IP, but only when
|
||||||
|
// the stored value is missing or older than an hour — so it costs at most one write per
|
||||||
|
// account per hour (its caller, the profile fetch, runs once per cold app-load). It is a
|
||||||
|
// best-effort audit signal that feeds the account-deletion dossier.
|
||||||
|
func (s *Store) StampLastLogin(ctx context.Context, accountID uuid.UUID, ip string) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
upd := table.Accounts.UPDATE(table.Accounts.LastLoginAt, table.Accounts.LastLoginIP).
|
||||||
|
SET(postgres.TimestampzT(now), postgres.String(ip)).
|
||||||
|
WHERE(
|
||||||
|
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
|
AND(
|
||||||
|
table.Accounts.LastLoginAt.IS_NULL().
|
||||||
|
OR(table.Accounts.LastLoginAt.LT(postgres.TimestampzT(now.Add(-time.Hour)))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if _, err := upd.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return fmt.Errorf("account: stamp last login %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReapExpiredRetention purges retention data whose event is older than cutoff: every
|
||||||
|
// retained_identities row by its detached_at (covering unlink/change on live accounts as
|
||||||
|
// well as deleted ones), plus — for accounts tombstoned before cutoff — the retained
|
||||||
|
// feedback thread and the dossier PII (deleted_display_name, last_login_ip). Chat is kept
|
||||||
|
// (a shared game artifact), and the tombstone account row itself stays (its no-cascade
|
||||||
|
// foreign keys). It returns how many journal rows and feedback messages were removed.
|
||||||
|
func (s *Store) ReapExpiredRetention(ctx context.Context, cutoff time.Time) (identities, feedback int64, err error) {
|
||||||
|
cut := postgres.TimestampzT(cutoff)
|
||||||
|
delJournal := table.RetainedIdentities.DELETE().
|
||||||
|
WHERE(table.RetainedIdentities.DetachedAt.LT(cut))
|
||||||
|
res, err := delJournal.ExecContext(ctx, s.db)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("account: reap retained identities: %w", err)
|
||||||
|
}
|
||||||
|
identities, _ = res.RowsAffected()
|
||||||
|
|
||||||
|
expired := postgres.SELECT(table.Accounts.AccountID).
|
||||||
|
FROM(table.Accounts).
|
||||||
|
WHERE(table.Accounts.DeletedAt.IS_NOT_NULL().AND(table.Accounts.DeletedAt.LT(cut)))
|
||||||
|
delFeedback := table.FeedbackMessages.DELETE().
|
||||||
|
WHERE(table.FeedbackMessages.AccountID.IN(expired))
|
||||||
|
fbRes, err := delFeedback.ExecContext(ctx, s.db)
|
||||||
|
if err != nil {
|
||||||
|
return identities, 0, fmt.Errorf("account: reap deleted feedback: %w", err)
|
||||||
|
}
|
||||||
|
feedback, _ = fbRes.RowsAffected()
|
||||||
|
|
||||||
|
clearPII := table.Accounts.UPDATE(table.Accounts.DeletedDisplayName, table.Accounts.LastLoginIP).
|
||||||
|
SET(postgres.NULL, postgres.NULL).
|
||||||
|
WHERE(
|
||||||
|
table.Accounts.DeletedAt.IS_NOT_NULL().
|
||||||
|
AND(table.Accounts.DeletedAt.LT(cut)).
|
||||||
|
AND(table.Accounts.DeletedDisplayName.IS_NOT_NULL().
|
||||||
|
OR(table.Accounts.LastLoginIP.IS_NOT_NULL())),
|
||||||
|
)
|
||||||
|
if _, err := clearPII.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return identities, feedback, fmt.Errorf("account: clear expired dossier PII: %w", err)
|
||||||
|
}
|
||||||
|
return identities, feedback, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetainedIdentity is one row of the retention journal, for the admin dossier.
|
||||||
|
type RetainedIdentity struct {
|
||||||
|
Kind string
|
||||||
|
ExternalID string
|
||||||
|
Reason string
|
||||||
|
Confirmed bool
|
||||||
|
LinkedAt time.Time
|
||||||
|
DetachedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetainedIdentities returns the account's retention-journal rows (the legal dossier of
|
||||||
|
// detached credentials), newest detach first, for the admin console.
|
||||||
|
func (s *Store) RetainedIdentities(ctx context.Context, accountID uuid.UUID) ([]RetainedIdentity, error) {
|
||||||
|
var rows []model.RetainedIdentities
|
||||||
|
err := postgres.SELECT(table.RetainedIdentities.AllColumns).
|
||||||
|
FROM(table.RetainedIdentities).
|
||||||
|
WHERE(table.RetainedIdentities.AccountID.EQ(postgres.UUID(accountID))).
|
||||||
|
ORDER_BY(table.RetainedIdentities.DetachedAt.DESC()).
|
||||||
|
QueryContext(ctx, s.db, &rows)
|
||||||
|
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("account: retained identities %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
out := make([]RetainedIdentity, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, RetainedIdentity{
|
||||||
|
Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
|
||||||
|
Confirmed: r.Confirmed, LinkedAt: r.LinkedAt, DetachedAt: r.DetachedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletionInfo is a tombstoned account's dossier header, for the admin console.
|
||||||
|
type DeletionInfo struct {
|
||||||
|
DeletedAt *time.Time
|
||||||
|
DeletedDisplayName string
|
||||||
|
LastLoginAt *time.Time
|
||||||
|
LastLoginIP string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletionInfo reads the account's deletion tombstone + last-login dossier fields.
|
||||||
|
func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (DeletionInfo, error) {
|
||||||
|
var row model.Accounts
|
||||||
|
err := postgres.SELECT(
|
||||||
|
table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName,
|
||||||
|
table.Accounts.LastLoginAt, table.Accounts.LastLoginIP,
|
||||||
|
).FROM(table.Accounts).
|
||||||
|
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))).
|
||||||
|
QueryContext(ctx, s.db, &row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return DeletionInfo{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return DeletionInfo{}, fmt.Errorf("account: deletion info %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
info := DeletionInfo{DeletedAt: row.DeletedAt, LastLoginAt: row.LastLoginAt}
|
||||||
|
if row.DeletedDisplayName != nil {
|
||||||
|
info.DeletedDisplayName = *row.DeletedDisplayName
|
||||||
|
}
|
||||||
|
if row.LastLoginIP != nil {
|
||||||
|
info.LastLoginIP = *row.LastLoginIP
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetentionReaper periodically purges expired account-deletion retention data via
|
||||||
|
// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once
|
||||||
|
// from main.
|
||||||
|
type RetentionReaper struct {
|
||||||
|
store *Store
|
||||||
|
ttl time.Duration
|
||||||
|
clock func() time.Time
|
||||||
|
log *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRetentionReaper constructs a reaper purging retention data older than ttl. log may be
|
||||||
|
// nil.
|
||||||
|
func NewRetentionReaper(store *Store, ttl time.Duration, log *zap.Logger) *RetentionReaper {
|
||||||
|
if log == nil {
|
||||||
|
log = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &RetentionReaper{
|
||||||
|
store: store,
|
||||||
|
ttl: ttl,
|
||||||
|
clock: func() time.Time { return time.Now().UTC() },
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run purges expired retention data on each tick until ctx is cancelled.
|
||||||
|
func (r *RetentionReaper) Run(ctx context.Context, interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
idn, fb, err := r.store.ReapExpiredRetention(ctx, r.clock().Add(-r.ttl))
|
||||||
|
if err != nil {
|
||||||
|
r.log.Warn("retention reap failed", zap.Error(err))
|
||||||
|
} else if idn > 0 || fb > 0 {
|
||||||
|
r.log.Info("reaped expired retention", zap.Int64("identities", idn), zap.Int64("feedback", fb))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ type UserListItem struct {
|
|||||||
PreferredLanguage string
|
PreferredLanguage string
|
||||||
IsGuest bool
|
IsGuest bool
|
||||||
IsRobot bool
|
IsRobot bool
|
||||||
|
// IsDeleted marks a tombstoned account (deleted_at set), shown as a badge — a search
|
||||||
|
// spans both lists, so a result can be either live or deleted.
|
||||||
|
IsDeleted bool
|
||||||
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
|
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
|
||||||
// as a badge in the console list.
|
// as a badge in the console list.
|
||||||
FlaggedHighRateAt time.Time
|
FlaggedHighRateAt time.Time
|
||||||
@@ -26,13 +29,17 @@ type UserListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
|
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
|
||||||
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
|
// non-robot "people"); Deleted selects tombstoned accounts (every other scope hides them);
|
||||||
// one char) matched case-insensitively against the display name / any identity's external
|
// NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = one char) matched
|
||||||
// id. An empty mask means no filter on that field.
|
// case-insensitively against the display name / any identity's external id; EmailExact is a
|
||||||
|
// strict (exact) match against an account's email identity. An empty value means no filter
|
||||||
|
// on that field.
|
||||||
type UserFilter struct {
|
type UserFilter struct {
|
||||||
Robots bool
|
Robots bool
|
||||||
|
Deleted bool
|
||||||
NameMask string
|
NameMask string
|
||||||
ExternalIDMask string
|
ExternalIDMask string
|
||||||
|
EmailExact string
|
||||||
}
|
}
|
||||||
|
|
||||||
// robotExists is the correlated subquery testing whether account a is a robot.
|
// robotExists is the correlated subquery testing whether account a is a robot.
|
||||||
@@ -51,17 +58,42 @@ func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error)
|
|||||||
return ok, nil
|
return ok, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// userListWhere builds the shared WHERE clause and its positional args (from $1).
|
// userListWhere builds the shared WHERE clause and its positional args (from $1). On the
|
||||||
|
// Robots tab it lists/searches robots only. Otherwise a search (any of the name /
|
||||||
|
// external-id / email filters) spans live and deleted people alike — never robots — so the
|
||||||
|
// operator finds a match from one query regardless of the People / Deleted tab; the search
|
||||||
|
// also looks in the retention journal, so a deleted account is still found by the email /
|
||||||
|
// external id it held (those rows moved from identities to retained_identities on deletion)
|
||||||
|
// and by its retained real name. With no search, the People / Deleted tab scope applies.
|
||||||
func userListWhere(f UserFilter) (string, []any) {
|
func userListWhere(f UserFilter) (string, []any) {
|
||||||
args := []any{f.Robots}
|
name := LikePattern(f.NameMask)
|
||||||
where := robotExists + ` = $1`
|
ext := LikePattern(f.ExternalIDMask)
|
||||||
if name := LikePattern(f.NameMask); name != "" {
|
email := strings.ToLower(strings.TrimSpace(f.EmailExact))
|
||||||
args = append(args, name)
|
searching := name != "" || ext != "" || email != ""
|
||||||
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
|
|
||||||
|
var args []any
|
||||||
|
var where string
|
||||||
|
switch {
|
||||||
|
case f.Robots:
|
||||||
|
where = robotExists + ` = true`
|
||||||
|
case searching:
|
||||||
|
where = robotExists + ` = false`
|
||||||
|
case f.Deleted:
|
||||||
|
where = robotExists + ` = false AND a.deleted_at IS NOT NULL`
|
||||||
|
default:
|
||||||
|
where = robotExists + ` = false AND a.deleted_at IS NULL`
|
||||||
}
|
}
|
||||||
if ext := LikePattern(f.ExternalIDMask); ext != "" {
|
if name != "" {
|
||||||
|
args = append(args, name)
|
||||||
|
where += fmt.Sprintf(` AND (a.display_name ILIKE $%d ESCAPE '\' OR a.deleted_display_name ILIKE $%d ESCAPE '\')`, len(args), len(args))
|
||||||
|
}
|
||||||
|
if ext != "" {
|
||||||
args = append(args, ext)
|
args = append(args, ext)
|
||||||
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
|
where += fmt.Sprintf(` AND (EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\') OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.external_id ILIKE $%d ESCAPE '\'))`, len(args), len(args))
|
||||||
|
}
|
||||||
|
if email != "" {
|
||||||
|
args = append(args, email)
|
||||||
|
where += fmt.Sprintf(` AND (EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d) OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.kind = 'email' AND r.external_id = $%d))`, len(args), len(args))
|
||||||
}
|
}
|
||||||
return where, args
|
return where, args
|
||||||
}
|
}
|
||||||
@@ -69,7 +101,7 @@ func userListWhere(f UserFilter) (string, []any) {
|
|||||||
// ListUsers returns the filtered admin user list, newest first, paginated.
|
// ListUsers returns the filtered admin user list, newest first, paginated.
|
||||||
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
|
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
|
||||||
where, args := userListWhere(f)
|
where, args := userListWhere(f)
|
||||||
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot
|
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot, (a.deleted_at IS NOT NULL) AS is_deleted
|
||||||
FROM backend.accounts a WHERE ` + where +
|
FROM backend.accounts a WHERE ` + where +
|
||||||
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
@@ -82,7 +114,7 @@ FROM backend.accounts a WHERE ` + where +
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var it UserListItem
|
var it UserListItem
|
||||||
var flagged sql.NullTime
|
var flagged sql.NullTime
|
||||||
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil {
|
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot, &it.IsDeleted); err != nil {
|
||||||
return nil, fmt.Errorf("account: scan user: %w", err)
|
return nil, fmt.Errorf("account: scan user: %w", err)
|
||||||
}
|
}
|
||||||
if flagged.Valid {
|
if flagged.Valid {
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// Package accountdelete deactivates an account as legal retention, not erasure: it keeps
|
||||||
|
// the account row as a tombstone (its chat/complaint foreign keys have no cascade, so a
|
||||||
|
// hard delete is impossible) while journalling and freeing the account's credentials,
|
||||||
|
// anonymising the live surfaces, and dropping the account's own social/ephemeral rows.
|
||||||
|
// The retained_identities journal plus the tombstone (deleted_at, deleted_display_name,
|
||||||
|
// last_login_at/ip) form the admin/legal dossier; messages are deliberately kept. Session
|
||||||
|
// revocation and active-game forfeit are orchestrated one layer up (they need the session
|
||||||
|
// cache and the game service). See docs/ARCHITECTURE.md §9.1 and the retention TTL reaper.
|
||||||
|
package accountdelete
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnonymizedName is the label a deleted account shows to opponents. Display names are
|
||||||
|
// stored strings resolved identically for every viewer (no per-viewer localisation in this
|
||||||
|
// codebase), so a single canonical label is used. The brackets are deliberate: the
|
||||||
|
// editable-name rule (account.displayNameRe) forbids them, so a live player can never set a
|
||||||
|
// name that impersonates a deleted account.
|
||||||
|
const AnonymizedName = "[Deleted]"
|
||||||
|
|
||||||
|
// retainDelete is the retained_identities reason written when a credential is journalled
|
||||||
|
// because its account is being deleted.
|
||||||
|
const retainDelete = "delete"
|
||||||
|
|
||||||
|
// Deleter performs the SQL-atomic part of account deletion over a Postgres handle.
|
||||||
|
type Deleter struct {
|
||||||
|
db *sql.DB
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDeleter constructs a Deleter over db.
|
||||||
|
func NewDeleter(db *sql.DB) *Deleter {
|
||||||
|
return &Deleter{db: db, now: func() time.Time { return time.Now().UTC() }}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnonymizeAndTombstone retires accountID atomically: it journals every live identity into
|
||||||
|
// retained_identities (reason=delete) then removes them so the credentials free for reuse,
|
||||||
|
// snapshots the real display name into deleted_display_name and scrubs the live one to
|
||||||
|
// AnonymizedName, sets deleted_at, anonymises the account's game-seat snapshots, and drops
|
||||||
|
// its friendships, blocks, invitations, friend codes, drafts and pending codes. Chat,
|
||||||
|
// feedback and complaints are kept (the surviving tombstone keeps their no-cascade foreign
|
||||||
|
// keys valid). It is idempotent-safe on an already-tombstoned account (re-journalling
|
||||||
|
// nothing, since the identities are already gone).
|
||||||
|
func (d *Deleter) AnonymizeAndTombstone(ctx context.Context, accountID uuid.UUID) error {
|
||||||
|
now := d.now()
|
||||||
|
return withTx(ctx, d.db, func(tx *sql.Tx) error {
|
||||||
|
if err := journalAndDropIdentities(ctx, tx, accountID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tombstone(ctx, tx, accountID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := table.GamePlayers.UPDATE(table.GamePlayers.DisplayName).
|
||||||
|
SET(postgres.String(AnonymizedName)).
|
||||||
|
WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(accountID))).
|
||||||
|
ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: anonymise seats: %w", err)
|
||||||
|
}
|
||||||
|
return dropSocialAndEphemerals(ctx, tx, accountID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropAllRobotGamesSQL deletes every game in which the account plays and no other seat is a
|
||||||
|
// human — a robot seat is one whose account holds a 'robot' identity, so this covers both
|
||||||
|
// honest vs-AI games and disguised auto-match substitutes. The game rows are deleted; their
|
||||||
|
// moves/chat/players/complaints fall away through ON DELETE CASCADE.
|
||||||
|
const dropAllRobotGamesSQL = `
|
||||||
|
DELETE FROM games g
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM game_players p WHERE p.game_id = g.game_id AND p.account_id = $1
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM game_players o
|
||||||
|
WHERE o.game_id = g.game_id AND o.account_id <> $1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM identities i WHERE i.account_id = o.account_id AND i.kind = 'robot'
|
||||||
|
)
|
||||||
|
)`
|
||||||
|
|
||||||
|
// DropAllRobotGames deletes the account's games that have no human opponent (solo vs-AI or
|
||||||
|
// auto-match-robot games), returning how many were removed. Games with any human seat are
|
||||||
|
// kept — their seat is anonymised by AnonymizeAndTombstone instead. Run it after the
|
||||||
|
// account's active games are resigned, so no live game is removed under the robot driver.
|
||||||
|
func (d *Deleter) DropAllRobotGames(ctx context.Context, accountID uuid.UUID) (int64, error) {
|
||||||
|
res, err := d.db.ExecContext(ctx, dropAllRobotGamesSQL, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("accountdelete: drop all-robot games: %w", err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("accountdelete: dropped games count: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalAndDropIdentities copies the account's live identities into the retention journal
|
||||||
|
// (reason=delete) and then removes them, freeing each (kind, external_id) for reuse.
|
||||||
|
func journalAndDropIdentities(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error {
|
||||||
|
var ids []model.Identities
|
||||||
|
err := postgres.SELECT(table.Identities.AllColumns).
|
||||||
|
FROM(table.Identities).
|
||||||
|
WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
|
||||||
|
QueryContext(ctx, tx, &ids)
|
||||||
|
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return fmt.Errorf("accountdelete: load identities: %w", err)
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
rid, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: new retained id: %w", err)
|
||||||
|
}
|
||||||
|
ins := table.RetainedIdentities.INSERT(
|
||||||
|
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
|
||||||
|
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
|
||||||
|
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
|
||||||
|
table.RetainedIdentities.DetachedAt, table.RetainedIdentities.Reason,
|
||||||
|
).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, now, retainDelete)
|
||||||
|
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: retain identity %s: %w", id.Kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := table.Identities.DELETE().
|
||||||
|
WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
|
||||||
|
ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: delete identities: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tombstone marks the account deleted, snapshotting the real display name into
|
||||||
|
// deleted_display_name (evaluated from the old row) before scrubbing the live one.
|
||||||
|
func tombstone(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error {
|
||||||
|
upd := table.Accounts.UPDATE(
|
||||||
|
table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName,
|
||||||
|
table.Accounts.DisplayName, table.Accounts.UpdatedAt,
|
||||||
|
).SET(
|
||||||
|
postgres.TimestampzT(now), table.Accounts.DisplayName,
|
||||||
|
postgres.String(AnonymizedName), postgres.TimestampzT(now),
|
||||||
|
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID)))
|
||||||
|
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: tombstone account: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropSocialAndEphemerals removes the account's own friendships, blocks, invitations
|
||||||
|
// (as inviter and as invitee), friend codes, drafts and pending confirm-codes. These are
|
||||||
|
// the deleting user's private data with no dossier value; chat and feedback are kept.
|
||||||
|
func dropSocialAndEphemerals(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) error {
|
||||||
|
id := postgres.UUID(accountID)
|
||||||
|
// Friendships and blocks are two-account edges keyed on either endpoint.
|
||||||
|
if _, err := table.Friendships.DELETE().
|
||||||
|
WHERE(table.Friendships.RequesterID.EQ(id).OR(table.Friendships.AddresseeID.EQ(id))).
|
||||||
|
ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: delete friendships: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := table.Blocks.DELETE().
|
||||||
|
WHERE(table.Blocks.BlockerID.EQ(id).OR(table.Blocks.BlockedID.EQ(id))).
|
||||||
|
ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: delete blocks: %w", err)
|
||||||
|
}
|
||||||
|
// Invitations: drop the account's invitee rows, then its own invitations' invitees and
|
||||||
|
// the invitations themselves (children first, to respect the foreign key).
|
||||||
|
if _, err := table.GameInvitationInvitees.DELETE().
|
||||||
|
WHERE(table.GameInvitationInvitees.AccountID.EQ(id)).
|
||||||
|
ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: delete invitee rows: %w", err)
|
||||||
|
}
|
||||||
|
ownInvitations := postgres.SELECT(table.GameInvitations.InvitationID).
|
||||||
|
FROM(table.GameInvitations).
|
||||||
|
WHERE(table.GameInvitations.InviterID.EQ(id))
|
||||||
|
if _, err := table.GameInvitationInvitees.DELETE().
|
||||||
|
WHERE(table.GameInvitationInvitees.InvitationID.IN(ownInvitations)).
|
||||||
|
ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: delete own invitation invitees: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := table.GameInvitations.DELETE().
|
||||||
|
WHERE(table.GameInvitations.InviterID.EQ(id)).
|
||||||
|
ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: delete invitations: %w", err)
|
||||||
|
}
|
||||||
|
// Ephemerals: friend codes, move drafts, pending confirm-codes.
|
||||||
|
if _, err := table.FriendCodes.DELETE().
|
||||||
|
WHERE(table.FriendCodes.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: delete friend codes: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := table.GameDrafts.DELETE().
|
||||||
|
WHERE(table.GameDrafts.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: delete drafts: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := table.EmailConfirmations.DELETE().
|
||||||
|
WHERE(table.EmailConfirmations.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: delete confirmations: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTx runs fn inside a transaction, committing on success and rolling back on error.
|
||||||
|
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: begin tx: %w", err)
|
||||||
|
}
|
||||||
|
if err := fn(tx); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("accountdelete: commit tx: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -27,6 +27,11 @@ import (
|
|||||||
// without taking a dependency on the game package.
|
// without taking a dependency on the game package.
|
||||||
const statusActive = "active"
|
const statusActive = "active"
|
||||||
|
|
||||||
|
// retainReasonMerge is the retained_identities.reason for a credential dropped by a merge
|
||||||
|
// collision (both accounts held the same kind). It mirrors the account package's retain
|
||||||
|
// reasons, kept local to avoid importing that package's unexported constants.
|
||||||
|
const retainReasonMerge = "merge"
|
||||||
|
|
||||||
// Friendship statuses, highest precedence first, mirroring internal/social.
|
// Friendship statuses, highest precedence first, mirroring internal/social.
|
||||||
const (
|
const (
|
||||||
friendAccepted = "accepted"
|
friendAccepted = "accepted"
|
||||||
@@ -75,6 +80,9 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
|
|||||||
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := dedupeIdentities(ctx, tx, primary, secondary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
|
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
|
||||||
return fmt.Errorf("accountmerge: identities: %w", err)
|
return fmt.Errorf("accountmerge: identities: %w", err)
|
||||||
}
|
}
|
||||||
@@ -300,6 +308,77 @@ func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col pos
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dedupeIdentities resolves a same-kind identity collision before the blanket identity
|
||||||
|
// reassign: when both accounts already hold an identity of the same kind (e.g. each has a
|
||||||
|
// confirmed email — reachable when two email-bearing accounts merge), the primary keeps
|
||||||
|
// its own and the secondary's is journaled to retained_identities (reason=merge) and
|
||||||
|
// removed. Without this the blanket reassign would leave the survivor with two identities
|
||||||
|
// of one kind (there is no per-account-kind unique on identities), which the profile and
|
||||||
|
// the retention dossier both treat as singular. Non-colliding identities are untouched and
|
||||||
|
// move with the blanket reassign.
|
||||||
|
func dedupeIdentities(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||||
|
var prows []model.Identities
|
||||||
|
if err := postgres.SELECT(table.Identities.Kind).
|
||||||
|
FROM(table.Identities).
|
||||||
|
WHERE(table.Identities.AccountID.EQ(postgres.UUID(primary))).
|
||||||
|
QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return fmt.Errorf("accountmerge: primary identity kinds: %w", err)
|
||||||
|
}
|
||||||
|
occupied := make(map[string]struct{}, len(prows))
|
||||||
|
for _, r := range prows {
|
||||||
|
occupied[r.Kind] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(occupied) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var srows []model.Identities
|
||||||
|
if err := postgres.SELECT(
|
||||||
|
table.Identities.Kind, table.Identities.ExternalID,
|
||||||
|
table.Identities.Confirmed, table.Identities.CreatedAt,
|
||||||
|
).FROM(table.Identities).
|
||||||
|
WHERE(table.Identities.AccountID.EQ(postgres.UUID(secondary))).
|
||||||
|
QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return fmt.Errorf("accountmerge: secondary identities: %w", err)
|
||||||
|
}
|
||||||
|
for _, s := range srows {
|
||||||
|
if _, dup := occupied[s.Kind]; !dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := retainMergedIdentity(ctx, tx, secondary, s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
del := table.Identities.DELETE().WHERE(
|
||||||
|
table.Identities.AccountID.EQ(postgres.UUID(secondary)).
|
||||||
|
AND(table.Identities.Kind.EQ(postgres.String(s.Kind))).
|
||||||
|
AND(table.Identities.ExternalID.EQ(postgres.String(s.ExternalID))),
|
||||||
|
)
|
||||||
|
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountmerge: drop colliding %s identity: %w", s.Kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// retainMergedIdentity appends a retained_identities row for a secondary identity dropped
|
||||||
|
// by a merge collision (reason=merge), preserving it in the legal dossier. It mirrors
|
||||||
|
// account.retainIdentityTx, which is unexported; detached_at falls to the column default.
|
||||||
|
func retainMergedIdentity(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, id model.Identities) error {
|
||||||
|
rid, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("accountmerge: new retained id: %w", err)
|
||||||
|
}
|
||||||
|
ins := table.RetainedIdentities.INSERT(
|
||||||
|
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
|
||||||
|
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
|
||||||
|
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
|
||||||
|
table.RetainedIdentities.Reason,
|
||||||
|
).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainReasonMerge)
|
||||||
|
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountmerge: retain merged %s identity: %w", id.Kind, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// friendRank ranks a friendship status for dedupe precedence (higher wins).
|
// friendRank ranks a friendship status for dedupe precedence (higher wins).
|
||||||
func friendRank(status string) int {
|
func friendRank(status string) int {
|
||||||
switch status {
|
switch status {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Package adminalert emails the operator when new player feedback or word complaints
|
||||||
|
// arrive, coalescing a burst into a single digest per interval so a flood is one email,
|
||||||
|
// not N. It is inert unless an admin sender and recipient are configured. The sender is
|
||||||
|
// distinct from the user-facing confirm-code From, and the recipient may be several
|
||||||
|
// comma-separated addresses (the mailer splits them).
|
||||||
|
package adminalert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedbackCounter counts feedback created since a time (satisfied by feedback.Service).
|
||||||
|
type FeedbackCounter interface {
|
||||||
|
CountSince(ctx context.Context, since time.Time) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComplaintCounter counts word complaints filed since a time (satisfied by game.Service).
|
||||||
|
type ComplaintCounter interface {
|
||||||
|
CountComplaintsSince(ctx context.Context, since time.Time) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifier polls for new feedback and complaints and emails the operator a digest.
|
||||||
|
type Notifier struct {
|
||||||
|
mailer account.Mailer
|
||||||
|
feedback FeedbackCounter
|
||||||
|
complaints ComplaintCounter
|
||||||
|
from string
|
||||||
|
to string
|
||||||
|
consoleURL string
|
||||||
|
clock func() time.Time
|
||||||
|
log *zap.Logger
|
||||||
|
last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Notifier. from and to are the alert sender and recipient(s); consoleURL,
|
||||||
|
// when non-empty, is the admin-console link included in the email. log may be nil. The
|
||||||
|
// watermark starts at "now", so only items arriving after start-up are reported.
|
||||||
|
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier {
|
||||||
|
if log == nil {
|
||||||
|
log = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &Notifier{
|
||||||
|
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, consoleURL: consoleURL,
|
||||||
|
clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run polls on each tick until ctx is cancelled.
|
||||||
|
func (n *Notifier) Run(ctx context.Context, interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
n.tick(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tick counts what arrived since the last watermark and, if anything did, emails one
|
||||||
|
// digest. The watermark only advances after a successful send (or a quiet tick), so a
|
||||||
|
// transient send failure is retried on the next tick — the counts simply grow.
|
||||||
|
func (n *Notifier) tick(ctx context.Context) {
|
||||||
|
now := n.clock()
|
||||||
|
fb, err := n.feedback.CountSince(ctx, n.last)
|
||||||
|
if err != nil {
|
||||||
|
n.log.Warn("admin alert: count feedback failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cp, err := n.complaints.CountComplaintsSince(ctx, n.last)
|
||||||
|
if err != nil {
|
||||||
|
n.log.Warn("admin alert: count complaints failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fb == 0 && cp == 0 {
|
||||||
|
n.last = now
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := n.mailer.Send(ctx, n.digest(fb, cp)); err != nil {
|
||||||
|
n.log.Warn("admin alert: send failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n.log.Info("admin alert sent", zap.Int("feedback", fb), zap.Int("complaints", cp))
|
||||||
|
n.last = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// digest builds the operator alert email for fb new feedback and cp new complaints.
|
||||||
|
func (n *Notifier) digest(fb, cp int) account.Message {
|
||||||
|
var parts []string
|
||||||
|
if fb > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d new feedback message(s)", fb))
|
||||||
|
}
|
||||||
|
if cp > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp))
|
||||||
|
}
|
||||||
|
summary := strings.Join(parts, ", ")
|
||||||
|
text := summary + "."
|
||||||
|
if n.consoleURL != "" {
|
||||||
|
text += "\n\nOpen the admin console: " + n.consoleURL
|
||||||
|
}
|
||||||
|
return account.Message{
|
||||||
|
From: n.from,
|
||||||
|
To: n.to,
|
||||||
|
Subject: "Erudit — " + summary,
|
||||||
|
Text: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package adminalert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The fakes ignore the watermark and return a fixed count, which is all the digest logic
|
||||||
|
// needs.
|
||||||
|
type fbCounter struct{ n int }
|
||||||
|
|
||||||
|
func (f fbCounter) CountSince(context.Context, time.Time) (int, error) { return f.n, nil }
|
||||||
|
|
||||||
|
type cpCounter struct{ n int }
|
||||||
|
|
||||||
|
func (c cpCounter) CountComplaintsSince(context.Context, time.Time) (int, error) { return c.n, nil }
|
||||||
|
|
||||||
|
type recordingMailer struct{ sent []account.Message }
|
||||||
|
|
||||||
|
func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
|
||||||
|
m.sent = append(m.sent, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifierSkipsWhenNothingNew(t *testing.T) {
|
||||||
|
mailer := &recordingMailer{}
|
||||||
|
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", "", nil)
|
||||||
|
n.tick(context.Background())
|
||||||
|
if len(mailer.sent) != 0 {
|
||||||
|
t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifierDigestsNewItems(t *testing.T) {
|
||||||
|
mailer := &recordingMailer{}
|
||||||
|
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", "https://erudit-game.ru/_gm", nil)
|
||||||
|
n.tick(context.Background())
|
||||||
|
if len(mailer.sent) != 1 {
|
||||||
|
t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent))
|
||||||
|
}
|
||||||
|
msg := mailer.sent[0]
|
||||||
|
if msg.From != "alerts@erudit-game.ru" || msg.To != "op@x.ru, two@x.ru" {
|
||||||
|
t.Errorf("digest addressing = From %q To %q", msg.From, msg.To)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") {
|
||||||
|
t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.Text, "/_gm") {
|
||||||
|
t.Errorf("digest body = %q, want the console link", msg.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,6 +101,29 @@
|
|||||||
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
|
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{{if .HasEmail}}
|
||||||
|
<form class="form" method="post" action="/_gm/users/{{.ID}}/remove-email" onsubmit="return confirm('Erase the email identity from this account? The address will be freed.')">
|
||||||
|
<button type="submit">Erase email</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Deletion & retention</h2>
|
||||||
|
{{if .LastLoginAt}}<p class="note">Last login: {{.LastLoginAt}}{{if .LastLoginIP}} — <code>{{.LastLoginIP}}</code>{{end}}</p>{{end}}
|
||||||
|
{{if .Deleted}}<p><span class="warn">Deleted</span> at {{.DeletedAt}}{{if .DeletedName}} — was <code>{{.DeletedName}}</code>{{end}}</p>{{end}}
|
||||||
|
{{if .Retained}}
|
||||||
|
<h3>Retention journal (legal dossier of detached credentials)</h3>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Kind</th><th>Credential</th><th>Reason</th><th>Detached</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Retained}}<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{.Reason}}</td><td>{{.DetachedAt}}</td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
{{if not .Deleted}}
|
||||||
|
<form class="form" method="post" action="/_gm/users/{{.ID}}/delete" onsubmit="return confirm('Delete this account? Its credentials are journalled and freed, its data anonymised, and its sessions revoked. This cannot be undone.')">
|
||||||
|
<button type="submit">Delete user</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Friends</h2>
|
<section class="panel"><h2>Friends</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
<nav class="subnav">
|
<nav class="subnav">
|
||||||
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
|
<a href="/_gm/users"{{if and (not .Robots) (not .Deleted)}} class="active"{{end}}>People</a> ·
|
||||||
|
<a href="/_gm/users?kind=deleted"{{if .Deleted}} class="active"{{end}}>Deleted</a> ·
|
||||||
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
|
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
|
||||||
</nav>
|
</nav>
|
||||||
<form class="form" method="get" action="/_gm/users">
|
<form class="form" method="get" action="/_gm/users">
|
||||||
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
|
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}{{if .Deleted}}<input type="hidden" name="kind" value="deleted">{{end}}
|
||||||
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
||||||
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
|
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
|
||||||
|
<input name="email" value="{{.EmailExact}}" placeholder="email (exact)" type="search">
|
||||||
<button type="submit">Filter</button>
|
<button type="submit">Filter</button>
|
||||||
</form>
|
</form>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
@@ -17,7 +19,7 @@
|
|||||||
{{range .Items}}
|
{{range .Items}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
||||||
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
|
<td>{{.DisplayName}}{{if .Deleted}} <span class="pill">deleted</span>{{end}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
|
||||||
<td>{{.Kind}}</td>
|
<td>{{.Kind}}</td>
|
||||||
<td>{{.Language}}</td>
|
<td>{{.Language}}</td>
|
||||||
<td>{{.CreatedAt}}</td>
|
<td>{{.CreatedAt}}</td>
|
||||||
|
|||||||
@@ -60,8 +60,10 @@ type UsersView struct {
|
|||||||
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
|
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
|
||||||
// percent-encoded again by the contextual escaper.
|
// percent-encoded again by the contextual escaper.
|
||||||
Robots bool
|
Robots bool
|
||||||
|
Deleted bool
|
||||||
NameMask string
|
NameMask string
|
||||||
ExternalIDMask string
|
ExternalIDMask string
|
||||||
|
EmailExact string
|
||||||
FilterQuery template.URL
|
FilterQuery template.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ type UserRow struct {
|
|||||||
Kind string
|
Kind string
|
||||||
Language string
|
Language string
|
||||||
Guest bool
|
Guest bool
|
||||||
|
Deleted bool
|
||||||
FlaggedHighRate bool
|
FlaggedHighRate bool
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
HasMoveStats bool
|
HasMoveStats bool
|
||||||
@@ -150,6 +153,15 @@ type UserDetailView struct {
|
|||||||
// MergedInto is the primary account id when this account has been retired by a
|
// MergedInto is the primary account id when this account has been retired by a
|
||||||
// merge, or empty for a live account.
|
// merge, or empty for a live account.
|
||||||
MergedInto string
|
MergedInto string
|
||||||
|
// The account-deletion dossier. Deleted marks a tombstoned account; DeletedAt and
|
||||||
|
// DeletedName are its deletion time and retained real name; LastLoginAt/IP are the
|
||||||
|
// last cold-load stamp (shown for any account); Retained is the credential journal.
|
||||||
|
Deleted bool
|
||||||
|
DeletedAt string
|
||||||
|
DeletedName string
|
||||||
|
LastLoginAt string
|
||||||
|
LastLoginIP string
|
||||||
|
Retained []RetainedRow
|
||||||
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
|
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
|
||||||
// empty for an unflagged account; the card shows it with the Clear action.
|
// empty for an unflagged account; the card shows it with the Clear action.
|
||||||
FlaggedHighRateAt string
|
FlaggedHighRateAt string
|
||||||
@@ -161,6 +173,8 @@ type UserDetailView struct {
|
|||||||
HasStats bool
|
HasStats bool
|
||||||
Stats StatsRow
|
Stats StatsRow
|
||||||
Identities []IdentityRow
|
Identities []IdentityRow
|
||||||
|
// HasEmail gates the "Erase email" action; set when the account carries an email identity.
|
||||||
|
HasEmail bool
|
||||||
Games []GameRow
|
Games []GameRow
|
||||||
// TelegramID and VKID are the account's platform external ids (empty when absent).
|
// TelegramID and VKID are the account's platform external ids (empty when absent).
|
||||||
// TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK
|
// TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK
|
||||||
@@ -234,6 +248,17 @@ type IdentityRow struct {
|
|||||||
CreatedAt string
|
CreatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetainedRow is one credential in the account-deletion retention journal (the legal
|
||||||
|
// dossier of detached credentials): what was detached, when, and why.
|
||||||
|
type RetainedRow struct {
|
||||||
|
Kind string
|
||||||
|
ExternalID string
|
||||||
|
Reason string
|
||||||
|
Confirmed bool
|
||||||
|
LinkedAt string
|
||||||
|
DetachedAt string
|
||||||
|
}
|
||||||
|
|
||||||
// GameRow is one game row in a list.
|
// GameRow is one game row in a list.
|
||||||
type GameRow struct {
|
type GameRow struct {
|
||||||
ID string
|
ID string
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ func Load() (Config, error) {
|
|||||||
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
|
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
|
||||||
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
|
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
|
||||||
TLS: os.Getenv("BACKEND_SMTP_TLS"),
|
TLS: os.Getenv("BACKEND_SMTP_TLS"),
|
||||||
|
AdminFrom: os.Getenv("BACKEND_SMTP_ADMIN_FROM"),
|
||||||
|
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
||||||
}
|
}
|
||||||
|
|
||||||
c := Config{
|
c := Config{
|
||||||
|
|||||||
@@ -195,6 +195,11 @@ func (svc *Service) CountUnread(ctx context.Context) (int, error) {
|
|||||||
return svc.store.CountUnread(ctx)
|
return svc.store.CountUnread(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountSince counts feedback created after since, for the operator alert worker.
|
||||||
|
func (svc *Service) CountSince(ctx context.Context, since time.Time) (int, error) {
|
||||||
|
return svc.store.CountSince(ctx, since)
|
||||||
|
}
|
||||||
|
|
||||||
// Attachment returns a message's file name and bytes, reporting false when absent.
|
// Attachment returns a message's file name and bytes, reporting false when absent.
|
||||||
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
|
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
|
||||||
return svc.store.Attachment(ctx, id)
|
return svc.store.Attachment(ctx, id)
|
||||||
|
|||||||
@@ -386,3 +386,15 @@ func (s *Store) CountUnread(ctx context.Context) (int, error) {
|
|||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountSince counts feedback messages created strictly after since — the operator alert
|
||||||
|
// worker's "new since the last check" signal.
|
||||||
|
func (s *Store) CountSince(ctx context.Context, since time.Time) (int, error) {
|
||||||
|
var n int
|
||||||
|
if err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM backend.feedback_messages WHERE created_at > $1`, since,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("feedback: count since: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1008,6 +1008,12 @@ func (svc *Service) CountComplaints(ctx context.Context, status string) (int, er
|
|||||||
return svc.store.CountComplaints(ctx, status)
|
return svc.store.CountComplaints(ctx, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountComplaintsSince counts word complaints filed after since, for the operator alert
|
||||||
|
// worker.
|
||||||
|
func (svc *Service) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) {
|
||||||
|
return svc.store.CountComplaintsSince(ctx, since)
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveComplaint closes a complaint with an operator disposition (reject /
|
// ResolveComplaint closes a complaint with an operator disposition (reject /
|
||||||
// accept_add / accept_remove) and an optional note. An accepted complaint then
|
// accept_add / accept_remove) and an optional note. An accepted complaint then
|
||||||
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
|
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
|
||||||
|
|||||||
@@ -1040,6 +1040,19 @@ func (s *Store) CountComplaints(ctx context.Context, status string) (int, error)
|
|||||||
return int(dest.Count), nil
|
return int(dest.Count), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountComplaintsSince counts word complaints filed strictly after since — the operator
|
||||||
|
// alert worker's "new since the last check" signal.
|
||||||
|
func (s *Store) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) {
|
||||||
|
stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")).
|
||||||
|
FROM(table.Complaints).
|
||||||
|
WHERE(table.Complaints.CreatedAt.GT(postgres.TimestampzT(since)))
|
||||||
|
var dest struct{ Count int64 }
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
|
||||||
|
return 0, fmt.Errorf("game: count complaints since: %w", err)
|
||||||
|
}
|
||||||
|
return int(dest.Count), nil
|
||||||
|
}
|
||||||
|
|
||||||
// ActiveGames returns the turn clocks of every in-progress game; the sweeper
|
// ActiveGames returns the turn clocks of every in-progress game; the sweeper
|
||||||
// filters them against the per-move deadline and the player's away window.
|
// filters them against the per-move deadline and the player's away window.
|
||||||
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
|
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRemoveEmailIdentity erases an account's email identity but refuses when the
|
||||||
|
// email is the account's only identity (which would leave it unreachable).
|
||||||
|
func TestRemoveEmailIdentity(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
|
||||||
|
// Email is the only identity → refuse.
|
||||||
|
solo, err := store.ProvisionEmail(ctx, "solo-"+uuid.NewString()+"@example.com", "", "en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision email: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.RemoveEmailIdentity(ctx, solo.ID); !errors.Is(err, account.ErrLastIdentity) {
|
||||||
|
t.Fatalf("remove last identity = %v, want ErrLastIdentity", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegram + email → erase the email, keep Telegram.
|
||||||
|
tg, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision telegram: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AttachIdentity(ctx, tg.ID, account.KindEmail, "dual-"+uuid.NewString()+"@example.com", true); err != nil {
|
||||||
|
t.Fatalf("attach email: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.RemoveEmailIdentity(ctx, tg.ID); err != nil {
|
||||||
|
t.Fatalf("remove email: %v", err)
|
||||||
|
}
|
||||||
|
ids, err := store.Identities(ctx, tg.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("identities: %v", err)
|
||||||
|
}
|
||||||
|
if len(ids) != 1 || ids[0].Kind != account.KindTelegram {
|
||||||
|
t.Errorf("identities after erase = %+v, want only telegram", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListUsersEmailExact matches accounts strictly (exactly) by their email identity.
|
||||||
|
func TestListUsersEmailExact(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
email := "find-" + uuid.NewString() + "@example.com"
|
||||||
|
acc, err := store.ProvisionEmail(ctx, email, "", "en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := store.ListUsers(ctx, account.UserFilter{EmailExact: email}, 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, it := range items {
|
||||||
|
if it.ID == acc.ID {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("the exact email filter did not find the account")
|
||||||
|
}
|
||||||
|
|
||||||
|
other, err := store.ListUsers(ctx, account.UserFilter{EmailExact: "nope-" + uuid.NewString() + "@example.com"}, 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list (no match): %v", err)
|
||||||
|
}
|
||||||
|
for _, it := range other {
|
||||||
|
if it.ID == acc.ID {
|
||||||
|
t.Error("a non-matching email filter must not return the account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/accountdelete"
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deletedFields reads a tombstoned account's retained real name and its deleted_at.
|
||||||
|
func deletedFields(t *testing.T, accountID uuid.UUID) (name string, deletedAt sql.NullTime) {
|
||||||
|
t.Helper()
|
||||||
|
var dn sql.NullString
|
||||||
|
err := testDB.QueryRowContext(context.Background(),
|
||||||
|
"SELECT deleted_display_name, deleted_at FROM accounts WHERE account_id = $1", accountID).
|
||||||
|
Scan(&dn, &deletedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read deleted fields %s: %v", accountID, err)
|
||||||
|
}
|
||||||
|
return dn.String, deletedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnonymizeAndTombstone: deletion journals + frees the credentials, tombstones the
|
||||||
|
// account, scrubs the live name while retaining the real one, and frees the creds for a
|
||||||
|
// new account to reuse.
|
||||||
|
func TestAnonymizeAndTombstone(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
deleter := accountdelete.NewDeleter(testDB)
|
||||||
|
|
||||||
|
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
email := "del-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
|
||||||
|
t.Fatalf("attach email: %v", err)
|
||||||
|
}
|
||||||
|
before, err := store.GetByID(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load before: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The live identities are gone.
|
||||||
|
if ids, err := store.Identities(ctx, acc.ID); err != nil || len(ids) != 0 {
|
||||||
|
t.Fatalf("identities after delete = %+v (err %v), want none", ids, err)
|
||||||
|
}
|
||||||
|
// Both credentials are journalled with reason=delete.
|
||||||
|
got := retainedRows(t, acc.ID)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("retained rows = %+v, want 2", got)
|
||||||
|
}
|
||||||
|
for _, r := range got {
|
||||||
|
if r.reason != "delete" {
|
||||||
|
t.Errorf("retained reason = %q, want delete", r.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The live name is scrubbed; the real one is retained; deleted_at is set.
|
||||||
|
after, err := store.GetByID(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load after: %v", err)
|
||||||
|
}
|
||||||
|
if after.DisplayName != accountdelete.AnonymizedName {
|
||||||
|
t.Errorf("live display name = %q, want %q", after.DisplayName, accountdelete.AnonymizedName)
|
||||||
|
}
|
||||||
|
name, deletedAt := deletedFields(t, acc.ID)
|
||||||
|
if name != before.DisplayName {
|
||||||
|
t.Errorf("retained name = %q, want %q", name, before.DisplayName)
|
||||||
|
}
|
||||||
|
if !deletedAt.Valid || time.Since(deletedAt.Time) > time.Minute {
|
||||||
|
t.Errorf("deleted_at = %+v, want a recent timestamp", deletedAt)
|
||||||
|
}
|
||||||
|
// The credentials are free: a new account can claim the same email.
|
||||||
|
other, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Other", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision other: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, email, true); err != nil {
|
||||||
|
t.Fatalf("email should be free after deletion, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeletionDossierReaders: after deletion the admin readers expose the credential
|
||||||
|
// journal and the tombstone dossier.
|
||||||
|
func TestDeletionDossierReaders(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
deleter := accountdelete.NewDeleter(testDB)
|
||||||
|
|
||||||
|
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "dos-"+uuid.NewString()+"@example.com", true); err != nil {
|
||||||
|
t.Fatalf("attach email: %v", err)
|
||||||
|
}
|
||||||
|
if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rets, err := store.RetainedIdentities(ctx, acc.ID)
|
||||||
|
if err != nil || len(rets) != 2 {
|
||||||
|
t.Fatalf("RetainedIdentities = (%+v, %v), want 2 rows", rets, err)
|
||||||
|
}
|
||||||
|
for _, r := range rets {
|
||||||
|
if r.Reason != "delete" {
|
||||||
|
t.Errorf("retained reason = %q, want delete", r.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := store.DeletionInfo(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeletionInfo: %v", err)
|
||||||
|
}
|
||||||
|
if info.DeletedAt == nil {
|
||||||
|
t.Error("DeletionInfo.DeletedAt should be set")
|
||||||
|
}
|
||||||
|
if info.DeletedDisplayName != "Иван" {
|
||||||
|
t.Errorf("DeletionInfo.DeletedDisplayName = %q, want Иван", info.DeletedDisplayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listHasID reports whether the user list contains accountID.
|
||||||
|
func listHasID(items []account.UserListItem, id uuid.UUID) bool {
|
||||||
|
for _, it := range items {
|
||||||
|
if it.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUserListDeletedFilter: a tombstoned account is hidden from the default People list and
|
||||||
|
// shown only under the Deleted scope.
|
||||||
|
func TestUserListDeletedFilter(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
deleter := accountdelete.NewDeleter(testDB)
|
||||||
|
|
||||||
|
live, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Live", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision live: %v", err)
|
||||||
|
}
|
||||||
|
goneTg := "tg-" + uuid.NewString()
|
||||||
|
gone, _, err := store.ProvisionTelegram(ctx, goneTg, "en", "", "Gone", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision gone: %v", err)
|
||||||
|
}
|
||||||
|
goneEmail := "gone-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := store.AttachIdentity(ctx, gone.ID, account.KindEmail, goneEmail, true); err != nil {
|
||||||
|
t.Fatalf("attach gone email: %v", err)
|
||||||
|
}
|
||||||
|
if err := deleter.AnonymizeAndTombstone(ctx, gone.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
people, err := store.ListUsers(ctx, account.UserFilter{}, 5000, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list people: %v", err)
|
||||||
|
}
|
||||||
|
if listHasID(people, gone.ID) {
|
||||||
|
t.Error("a deleted account must not appear in the default People list")
|
||||||
|
}
|
||||||
|
if !listHasID(people, live.ID) {
|
||||||
|
t.Error("a live account must appear in the default People list")
|
||||||
|
}
|
||||||
|
deleted, err := store.ListUsers(ctx, account.UserFilter{Deleted: true}, 5000, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list deleted: %v", err)
|
||||||
|
}
|
||||||
|
if !listHasID(deleted, gone.ID) {
|
||||||
|
t.Error("a deleted account must appear in the Deleted list")
|
||||||
|
}
|
||||||
|
if listHasID(deleted, live.ID) {
|
||||||
|
t.Error("a live account must not appear in the Deleted list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A search spans both lists and reaches the retention journal: a deleted account is
|
||||||
|
// still found by the email and external id it held (both moved to retained_identities
|
||||||
|
// on deletion, out of the live identities table).
|
||||||
|
byEmail, err := store.ListUsers(ctx, account.UserFilter{EmailExact: goneEmail}, 5000, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search by email: %v", err)
|
||||||
|
}
|
||||||
|
if !listHasID(byEmail, gone.ID) {
|
||||||
|
t.Error("a deleted account must be found by the email it held (retention journal)")
|
||||||
|
}
|
||||||
|
byExt, err := store.ListUsers(ctx, account.UserFilter{ExternalIDMask: goneTg}, 5000, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search by external id: %v", err)
|
||||||
|
}
|
||||||
|
if !listHasID(byExt, gone.ID) {
|
||||||
|
t.Error("a deleted account must be found by the external id it held (retention journal)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfirmCodeClearsGuest: confirming an email on a guest via ConfirmCode promotes it to
|
||||||
|
// a durable account (defence-in-depth — no confirmed-email path leaves is_guest set).
|
||||||
|
func TestConfirmCodeClearsGuest(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
|
guest, err := store.ProvisionGuest(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision guest: %v", err)
|
||||||
|
}
|
||||||
|
email := "cc-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := svc.RequestCode(ctx, guest.ID, email); err != nil {
|
||||||
|
t.Fatalf("request code: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ConfirmCode(ctx, guest.ID, email, sixDigit.FindString(mailer.lastBody)); err != nil {
|
||||||
|
t.Fatalf("confirm code: %v", err)
|
||||||
|
}
|
||||||
|
after, err := store.GetByID(ctx, guest.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if after.IsGuest {
|
||||||
|
t.Error("confirming an email must clear the guest flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human
|
||||||
|
// opponent.
|
||||||
|
func TestDropAllRobotGames(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
gsvc := newGameService()
|
||||||
|
robots := newRobotService(t, gsvc)
|
||||||
|
if err := robots.EnsurePool(ctx); err != nil {
|
||||||
|
t.Fatalf("ensure pool: %v", err)
|
||||||
|
}
|
||||||
|
mm := newMatchmaker(t, robots, time.Minute, 0)
|
||||||
|
deleter := accountdelete.NewDeleter(testDB)
|
||||||
|
|
||||||
|
user := provisionAccount(t)
|
||||||
|
other := provisionAccount(t)
|
||||||
|
|
||||||
|
aiRes, err := mm.StartVsAI(ctx, user, engine.VariantEnglish, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start vs AI: %v", err)
|
||||||
|
}
|
||||||
|
humanGame, err := gsvc.Create(ctx, game.CreateParams{
|
||||||
|
Variant: engine.VariantEnglish, Seats: []uuid.UUID{user, other}, TurnTimeout: time.Hour, Seed: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create human game: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := deleter.DropAllRobotGames(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("drop: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("dropped %d games, want 1 (the vs-AI game)", n)
|
||||||
|
}
|
||||||
|
if _, err := gsvc.GameByID(ctx, aiRes.Game.ID); err == nil {
|
||||||
|
t.Error("the vs-AI game should be dropped")
|
||||||
|
}
|
||||||
|
if _, err := gsvc.GameByID(ctx, humanGame.ID); err != nil {
|
||||||
|
t.Errorf("the human game should be kept, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteStepUpEmail: an email account's delete code verifies (wrong code rejected, no
|
||||||
|
// deeplink in the mail); a platform-only account has no email and cannot request a code.
|
||||||
|
func TestDeleteStepUpEmail(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
|
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "del-"+uuid.NewString()+"@example.com", true); err != nil {
|
||||||
|
t.Fatalf("attach email: %v", err)
|
||||||
|
}
|
||||||
|
if has, err := svc.HasEmail(ctx, acc.ID); err != nil || !has {
|
||||||
|
t.Fatalf("HasEmail = (%v, %v), want true", has, err)
|
||||||
|
}
|
||||||
|
if err := svc.RequestDeleteCode(ctx, acc.ID); err != nil {
|
||||||
|
t.Fatalf("request delete code: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(mailer.lastBody, "/confirm/") {
|
||||||
|
t.Error("a delete email must not carry a one-tap deeplink")
|
||||||
|
}
|
||||||
|
code := sixDigit.FindString(mailer.lastBody)
|
||||||
|
if err := svc.VerifyDeleteCode(ctx, acc.ID, "000000"); err == nil {
|
||||||
|
t.Error("a wrong delete code must be rejected")
|
||||||
|
}
|
||||||
|
if err := svc.VerifyDeleteCode(ctx, acc.ID, code); err != nil {
|
||||||
|
t.Fatalf("verify correct delete code: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
noEmail, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision no-email: %v", err)
|
||||||
|
}
|
||||||
|
if has, _ := svc.HasEmail(ctx, noEmail.ID); has {
|
||||||
|
t.Error("HasEmail must be false for a platform-only account")
|
||||||
|
}
|
||||||
|
if err := svc.RequestDeleteCode(ctx, noEmail.ID); !errors.Is(err, account.ErrNoEmail) {
|
||||||
|
t.Errorf("request delete for no-email account = %v, want ErrNoEmail", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -394,3 +394,32 @@ func TestEmailAccountSeedsDisplayName(t *testing.T) {
|
|||||||
t.Errorf("display name = %q, want the email local part %q", acc.DisplayName, local)
|
t.Errorf("display name = %q, want the email local part %q", acc.DisplayName, local)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConfirmByTokenLinkClearsGuest: binding an email to a guest via the deeplink
|
||||||
|
// promotes the guest to a durable account (the deeplink path must match the
|
||||||
|
// code-based link flow, which clears the guest flag).
|
||||||
|
func TestConfirmByTokenLinkClearsGuest(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
guest, err := store.ProvisionGuest(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision guest: %v", err)
|
||||||
|
}
|
||||||
|
email := "guest-link-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
|
if err := svc.RequestLinkCode(ctx, guest.ID, email); err != nil {
|
||||||
|
t.Fatalf("request link: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody)); err != nil {
|
||||||
|
t.Fatalf("confirm by token: %v", err)
|
||||||
|
}
|
||||||
|
acc, err := store.GetByID(ctx, guest.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if acc.IsGuest {
|
||||||
|
t.Error("linking an email via the deeplink must promote the guest to durable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
)
|
||||||
|
|
||||||
|
// emailOf returns the external id of the account's email identity, or "" when it has none.
|
||||||
|
func emailOf(t *testing.T, store *account.Store, id uuid.UUID) string {
|
||||||
|
t.Helper()
|
||||||
|
ids, err := store.Identities(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("identities %s: %v", id, err)
|
||||||
|
}
|
||||||
|
for _, i := range ids {
|
||||||
|
if i.Kind == account.KindEmail {
|
||||||
|
return i.ExternalID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlinkProviderKeepsOthers detaches one provider from a multi-identity account and
|
||||||
|
// refuses to remove the last remaining identity.
|
||||||
|
func TestUnlinkProviderKeepsOthers(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
|
||||||
|
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision telegram: %v", err)
|
||||||
|
}
|
||||||
|
email := "unlink-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
|
||||||
|
t.Fatalf("attach email: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing a kind the account does not hold reports not-found.
|
||||||
|
if err := store.RemoveIdentity(ctx, acc.ID, account.KindVK); !errors.Is(err, account.ErrNotFound) {
|
||||||
|
t.Fatalf("remove absent vk = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach Telegram: the email identity remains.
|
||||||
|
if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
|
||||||
|
t.Fatalf("remove telegram: %v", err)
|
||||||
|
}
|
||||||
|
ids, err := store.Identities(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("identities: %v", err)
|
||||||
|
}
|
||||||
|
if len(ids) != 1 || ids[0].Kind != account.KindEmail {
|
||||||
|
t.Fatalf("identities after unlink = %+v, want only email", ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The email is now the last identity, so unlinking it is refused.
|
||||||
|
if err := store.RemoveIdentity(ctx, acc.ID, account.KindEmail); !errors.Is(err, account.ErrLastIdentity) {
|
||||||
|
t.Fatalf("remove last email = %v, want ErrLastIdentity", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChangeEmailReplaces switches an account's confirmed email to a free address,
|
||||||
|
// freeing the old one.
|
||||||
|
func TestChangeEmailReplaces(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
|
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
oldAddr := "old-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil {
|
||||||
|
t.Fatalf("attach old email: %v", err)
|
||||||
|
}
|
||||||
|
newAddr := "new-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
|
if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
|
||||||
|
t.Fatalf("request change: %v", err)
|
||||||
|
}
|
||||||
|
code := sixDigit.FindString(mailer.lastBody)
|
||||||
|
if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, code); err != nil {
|
||||||
|
t.Fatalf("confirm change: %v", err)
|
||||||
|
}
|
||||||
|
if got := emailOf(t, store, acc.ID); got != newAddr {
|
||||||
|
t.Fatalf("email after change = %q, want %q", got, newAddr)
|
||||||
|
}
|
||||||
|
if !identityConfirmed(t, account.KindEmail, newAddr) {
|
||||||
|
t.Error("the new email identity must be confirmed")
|
||||||
|
}
|
||||||
|
// The old address is freed: another account can now claim it.
|
||||||
|
other, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision other: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, oldAddr, true); err != nil {
|
||||||
|
t.Fatalf("old address should be free after change, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChangeEmailRefusesTaken refuses (without merging) a new address already confirmed by
|
||||||
|
// another account, leaving the caller's email untouched.
|
||||||
|
func TestChangeEmailRefusesTaken(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
|
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision caller: %v", err)
|
||||||
|
}
|
||||||
|
mine := "mine-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, mine, true); err != nil {
|
||||||
|
t.Fatalf("attach caller email: %v", err)
|
||||||
|
}
|
||||||
|
other, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision other: %v", err)
|
||||||
|
}
|
||||||
|
taken := "taken-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, taken, true); err != nil {
|
||||||
|
t.Fatalf("attach other email: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.RequestChangeCode(ctx, acc.ID, taken); err != nil {
|
||||||
|
t.Fatalf("request change: %v", err)
|
||||||
|
}
|
||||||
|
code := sixDigit.FindString(mailer.lastBody)
|
||||||
|
if _, err := svc.ConfirmChange(ctx, acc.ID, taken, code); !errors.Is(err, account.ErrEmailTaken) {
|
||||||
|
t.Fatalf("confirm change to taken = %v, want ErrEmailTaken", err)
|
||||||
|
}
|
||||||
|
if got := emailOf(t, store, acc.ID); got != mine {
|
||||||
|
t.Errorf("caller email after refused change = %q, want unchanged %q", got, mine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChangeEmailViaDeeplink switches the email through the one-tap confirm token.
|
||||||
|
func TestChangeEmailViaDeeplink(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
|
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "old-"+uuid.NewString()+"@example.com", true); err != nil {
|
||||||
|
t.Fatalf("attach old email: %v", err)
|
||||||
|
}
|
||||||
|
newAddr := "dl-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
|
||||||
|
t.Fatalf("request change: %v", err)
|
||||||
|
}
|
||||||
|
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("confirm by token: %v", err)
|
||||||
|
}
|
||||||
|
if res.IsLogin() || res.NeedsMerge || res.Account != acc.ID {
|
||||||
|
t.Fatalf("deeplink change result = %+v, want a plain change for %s", res, acc.ID)
|
||||||
|
}
|
||||||
|
if got := emailOf(t, store, acc.ID); got != newAddr {
|
||||||
|
t.Fatalf("email after deeplink change = %q, want %q", got, newAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -251,6 +251,44 @@ func TestAccountMergeFinishedSharedGameKept(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAccountMergeDedupesEmail keeps the primary's email when both accounts have one and
|
||||||
|
// journals the secondary's to the dossier (reason=merge), so the survivor never ends up
|
||||||
|
// with two email identities.
|
||||||
|
func TestAccountMergeDedupesEmail(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
merger := accountmerge.NewMerger(testDB)
|
||||||
|
|
||||||
|
primary := provisionAccount(t)
|
||||||
|
secondary := provisionAccount(t)
|
||||||
|
primaryEmail := "keep-" + uuid.NewString() + "@example.com"
|
||||||
|
secondaryEmail := "absorb-" + uuid.NewString() + "@example.com"
|
||||||
|
bindEmailIdentity(t, primary, primaryEmail)
|
||||||
|
bindEmailIdentity(t, secondary, secondaryEmail)
|
||||||
|
|
||||||
|
if err := merger.Merge(ctx, primary, secondary); err != nil {
|
||||||
|
t.Fatalf("merge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The primary keeps its own email; the secondary's is gone from the live identities.
|
||||||
|
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, primaryEmail); !ok || owner != primary {
|
||||||
|
t.Errorf("primary email owner = %s ok=%v, want primary %s", owner, ok, primary)
|
||||||
|
}
|
||||||
|
if _, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, secondaryEmail); ok {
|
||||||
|
t.Error("the secondary's email must be removed (no duplicate email on the survivor)")
|
||||||
|
}
|
||||||
|
// The absorbed email stays in the legal dossier, tagged reason=merge.
|
||||||
|
var reason string
|
||||||
|
if err := testDB.QueryRowContext(ctx,
|
||||||
|
`SELECT reason FROM backend.retained_identities WHERE account_id=$1 AND kind='email' AND external_id=$2`,
|
||||||
|
secondary, secondaryEmail).Scan(&reason); err != nil {
|
||||||
|
t.Fatalf("retained email row: %v", err)
|
||||||
|
}
|
||||||
|
if reason != "merge" {
|
||||||
|
t.Errorf("retained reason = %q, want merge", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
|
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
|
||||||
func TestAccountLinkFreeEmail(t *testing.T) {
|
func TestAccountLinkFreeEmail(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -355,3 +393,64 @@ func TestAccountLinkGuestInversion(t *testing.T) {
|
|||||||
t.Errorf("email owner = %s, want durable", owner)
|
t.Errorf("email owner = %s, want durable", owner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
|
||||||
|
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
|
||||||
|
func TestAccountLinkFreeVK(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
links := newLinkService(&capturingMailer{})
|
||||||
|
|
||||||
|
guest := provisionGuest(t)
|
||||||
|
vkID := "vk-" + uuid.NewString()
|
||||||
|
res, err := links.ConfirmVK(ctx, guest, vkID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("confirm vk: %v", err)
|
||||||
|
}
|
||||||
|
if !res.Linked || res.MergeRequired {
|
||||||
|
t.Fatalf("confirm = %+v, want linked", res)
|
||||||
|
}
|
||||||
|
if acc, _ := store.GetByID(ctx, guest); acc.IsGuest {
|
||||||
|
t.Error("guest flag should clear once VK is linked")
|
||||||
|
}
|
||||||
|
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); !ok || owner != guest {
|
||||||
|
t.Errorf("vk owner = %s, want the promoted guest %s", owner, guest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAccountLinkVKMergeIntoCaller merges the account owning a VK identity into the
|
||||||
|
// current durable account: ConfirmVK previews the merge, MergeVK folds it, the caller
|
||||||
|
// stays primary and keeps its session, and the VK identity repoints to the caller.
|
||||||
|
func TestAccountLinkVKMergeIntoCaller(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
links := newLinkService(&capturingMailer{})
|
||||||
|
|
||||||
|
caller := provisionAccount(t)
|
||||||
|
other := provisionAccount(t)
|
||||||
|
vkID := "vk-" + uuid.NewString()
|
||||||
|
if err := store.AttachIdentity(ctx, other, account.KindVK, vkID, true); err != nil {
|
||||||
|
t.Fatalf("seed vk on other: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm, err := links.ConfirmVK(ctx, caller, vkID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("confirm vk: %v", err)
|
||||||
|
}
|
||||||
|
if !confirm.MergeRequired || confirm.SecondaryID != other {
|
||||||
|
t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
|
||||||
|
}
|
||||||
|
merge, err := links.MergeVK(ctx, caller, vkID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("merge vk: %v", err)
|
||||||
|
}
|
||||||
|
if merge.PrimaryID != caller || merge.SwitchedToken != "" {
|
||||||
|
t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
|
||||||
|
}
|
||||||
|
if mergedInto(t, other) != caller {
|
||||||
|
t.Error("other should be tombstoned into caller")
|
||||||
|
}
|
||||||
|
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); owner != caller {
|
||||||
|
t.Errorf("vk owner = %s, want caller after merge", owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/accountdelete"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lastLoginIP reads an account's stamped last-login IP ("" when unset).
|
||||||
|
func lastLoginIP(t *testing.T, accountID uuid.UUID) string {
|
||||||
|
t.Helper()
|
||||||
|
var ip sql.NullString
|
||||||
|
err := testDB.QueryRowContext(context.Background(),
|
||||||
|
"SELECT last_login_ip FROM accounts WHERE account_id = $1", accountID).Scan(&ip)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read last_login_ip %s: %v", accountID, err)
|
||||||
|
}
|
||||||
|
return ip.String
|
||||||
|
}
|
||||||
|
|
||||||
|
// retainedRow is one row of the retention journal, read directly for assertions.
|
||||||
|
type retainedRow struct {
|
||||||
|
kind, externalID, reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// retainedRows reads the retention journal for an account, oldest detach first.
|
||||||
|
func retainedRows(t *testing.T, accountID uuid.UUID) []retainedRow {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := testDB.QueryContext(context.Background(),
|
||||||
|
"SELECT kind, external_id, reason FROM retained_identities WHERE account_id = $1 ORDER BY detached_at",
|
||||||
|
accountID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query retained_identities %s: %v", accountID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []retainedRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r retainedRow
|
||||||
|
if err := rows.Scan(&r.kind, &r.externalID, &r.reason); err != nil {
|
||||||
|
t.Fatalf("scan retained row: %v", err)
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlinkJournalsRetainedIdentity: detaching a provider records it in the retention
|
||||||
|
// journal (reason=unlink) before the live identity is removed.
|
||||||
|
func TestUnlinkJournalsRetainedIdentity(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
|
||||||
|
tgExt := "tg-" + uuid.NewString()
|
||||||
|
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
email := "keep-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
|
||||||
|
t.Fatalf("attach email: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
|
||||||
|
t.Fatalf("unlink telegram: %v", err)
|
||||||
|
}
|
||||||
|
got := retainedRows(t, acc.ID)
|
||||||
|
if len(got) != 1 || got[0].kind != account.KindTelegram || got[0].externalID != tgExt || got[0].reason != "unlink" {
|
||||||
|
t.Fatalf("retained rows = %+v, want one unlink telegram %q", got, tgExt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChangeEmailJournalsOldAddress: an email change records the outgoing address in the
|
||||||
|
// retention journal (reason=change).
|
||||||
|
func TestChangeEmailJournalsOldAddress(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
mailer := &capturingMailer{}
|
||||||
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
|
|
||||||
|
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
oldAddr := "old-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil {
|
||||||
|
t.Fatalf("attach old email: %v", err)
|
||||||
|
}
|
||||||
|
newAddr := "new-" + uuid.NewString() + "@example.com"
|
||||||
|
if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
|
||||||
|
t.Fatalf("request change: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, sixDigit.FindString(mailer.lastBody)); err != nil {
|
||||||
|
t.Fatalf("confirm change: %v", err)
|
||||||
|
}
|
||||||
|
got := retainedRows(t, acc.ID)
|
||||||
|
if len(got) != 1 || got[0].kind != account.KindEmail || got[0].externalID != oldAddr || got[0].reason != "change" {
|
||||||
|
t.Fatalf("retained rows = %+v, want one change email %q", got, oldAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStampLastLoginThrottles: the first cold-load stamp writes the IP; a second within
|
||||||
|
// the hour is a no-op (throttled), so it costs at most one write per account per hour.
|
||||||
|
func TestStampLastLoginThrottles(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.StampLastLogin(ctx, acc.ID, "1.2.3.4"); err != nil {
|
||||||
|
t.Fatalf("first stamp: %v", err)
|
||||||
|
}
|
||||||
|
if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" {
|
||||||
|
t.Fatalf("first stamp ip = %q, want 1.2.3.4", got)
|
||||||
|
}
|
||||||
|
if err := store.StampLastLogin(ctx, acc.ID, "9.9.9.9"); err != nil {
|
||||||
|
t.Fatalf("second stamp: %v", err)
|
||||||
|
}
|
||||||
|
if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" {
|
||||||
|
t.Fatalf("throttled ip = %q, want unchanged 1.2.3.4", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReapExpiredRetention: the reaper keeps journal rows newer than the cutoff and purges
|
||||||
|
// older ones, and drops a long-deleted account's feedback thread + dossier PII.
|
||||||
|
func TestReapExpiredRetention(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
deleter := accountdelete.NewDeleter(testDB)
|
||||||
|
|
||||||
|
// An unlinked provider leaves a journal row detached "now".
|
||||||
|
tgExt := "tg-" + uuid.NewString()
|
||||||
|
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "keep-"+uuid.NewString()+"@example.com", true); err != nil {
|
||||||
|
t.Fatalf("attach email: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
|
||||||
|
t.Fatalf("unlink: %v", err)
|
||||||
|
}
|
||||||
|
// A cutoff before the detach keeps the row.
|
||||||
|
if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(-time.Hour)); err != nil {
|
||||||
|
t.Fatalf("reap (early cutoff): %v", err)
|
||||||
|
}
|
||||||
|
if got := retainedRows(t, acc.ID); len(got) != 1 {
|
||||||
|
t.Fatalf("journal after early-cutoff reap = %+v, want kept", got)
|
||||||
|
}
|
||||||
|
// A cutoff after the detach purges it.
|
||||||
|
if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatalf("reap (late cutoff): %v", err)
|
||||||
|
}
|
||||||
|
if got := retainedRows(t, acc.ID); len(got) != 0 {
|
||||||
|
t.Fatalf("journal after late-cutoff reap = %+v, want purged", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deleted account past the cutoff loses its feedback thread and dossier PII.
|
||||||
|
del, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Иван", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision deletee: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := testDB.ExecContext(ctx,
|
||||||
|
"INSERT INTO feedback_messages (message_id, account_id, body, channel) VALUES ($1, $2, 'hi', 'web')",
|
||||||
|
uuid.New(), del.ID); err != nil {
|
||||||
|
t.Fatalf("insert feedback: %v", err)
|
||||||
|
}
|
||||||
|
if err := deleter.AnonymizeAndTombstone(ctx, del.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, fb, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil || fb == 0 {
|
||||||
|
t.Fatalf("reap deleted = (fb %d, err %v), want fb>=1", fb, err)
|
||||||
|
}
|
||||||
|
if name, _ := deletedFields(t, del.ID); name != "" {
|
||||||
|
t.Errorf("deleted_display_name after reap = %q, want cleared", name)
|
||||||
|
}
|
||||||
|
var fbCount int
|
||||||
|
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM feedback_messages WHERE account_id = $1", del.ID).Scan(&fbCount); err != nil {
|
||||||
|
t.Fatalf("count feedback: %v", err)
|
||||||
|
}
|
||||||
|
if fbCount != 0 {
|
||||||
|
t.Errorf("feedback rows after reap = %d, want 0", fbCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,53 @@ func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, extern
|
|||||||
return s.accounts.ClearGuest(ctx, callerID)
|
return s.accounts.ClearGuest(ctx, callerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfirmVK attaches a gateway-validated VK identity to the caller (Linked) or
|
||||||
|
// reports that it belongs to another account (MergeRequired). The gateway has already
|
||||||
|
// completed the VK ID code exchange, so externalID is the trusted vk user id.
|
||||||
|
func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID string) (ConfirmResult, error) {
|
||||||
|
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
|
||||||
|
if err != nil {
|
||||||
|
return ConfirmResult{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
if err := s.attachVK(ctx, callerID, externalID); err != nil {
|
||||||
|
return ConfirmResult{}, err
|
||||||
|
}
|
||||||
|
return ConfirmResult{Linked: true}, nil
|
||||||
|
}
|
||||||
|
if owner == callerID {
|
||||||
|
return ConfirmResult{Linked: true}, nil
|
||||||
|
}
|
||||||
|
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
|
||||||
|
// (subject to the guest-primary rule).
|
||||||
|
func (s *Service) MergeVK(ctx context.Context, callerID uuid.UUID, externalID string) (MergeResult, error) {
|
||||||
|
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
|
||||||
|
if err != nil {
|
||||||
|
return MergeResult{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
if err := s.attachVK(ctx, callerID, externalID); err != nil {
|
||||||
|
return MergeResult{}, err
|
||||||
|
}
|
||||||
|
return MergeResult{PrimaryID: callerID}, nil
|
||||||
|
}
|
||||||
|
if owner == callerID {
|
||||||
|
return MergeResult{PrimaryID: callerID}, nil
|
||||||
|
}
|
||||||
|
return s.merge(ctx, callerID, owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachVK links the identity to the caller and promotes a guest.
|
||||||
|
func (s *Service) attachVK(ctx context.Context, callerID uuid.UUID, externalID string) error {
|
||||||
|
if err := s.accounts.AttachIdentity(ctx, callerID, account.KindVK, externalID, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.accounts.ClearGuest(ctx, callerID)
|
||||||
|
}
|
||||||
|
|
||||||
// merge decides the primary (the caller, unless it is a guest and the other is
|
// merge decides the primary (the caller, unless it is a guest and the other is
|
||||||
// durable), runs the data merge, retires the secondary's sessions and mints a new
|
// durable), runs the data merge, retires the secondary's sessions and mints a new
|
||||||
// session when the active account switches.
|
// session when the active account switches.
|
||||||
|
|||||||
@@ -32,4 +32,8 @@ type Accounts struct {
|
|||||||
MergedAt *time.Time
|
MergedAt *time.Time
|
||||||
FlaggedHighRateAt *time.Time
|
FlaggedHighRateAt *time.Time
|
||||||
VariantPreferences pq.StringArray
|
VariantPreferences pq.StringArray
|
||||||
|
LastLoginAt *time.Time
|
||||||
|
LastLoginIP *string
|
||||||
|
DeletedAt *time.Time
|
||||||
|
DeletedDisplayName *string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RetainedIdentities struct {
|
||||||
|
RetainedID uuid.UUID `sql:"primary_key"`
|
||||||
|
AccountID uuid.UUID
|
||||||
|
Kind string
|
||||||
|
ExternalID string
|
||||||
|
Confirmed bool
|
||||||
|
LinkedAt time.Time
|
||||||
|
DetachedAt time.Time
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
@@ -35,6 +35,10 @@ type accountsTable struct {
|
|||||||
MergedAt postgres.ColumnTimestampz
|
MergedAt postgres.ColumnTimestampz
|
||||||
FlaggedHighRateAt postgres.ColumnTimestampz
|
FlaggedHighRateAt postgres.ColumnTimestampz
|
||||||
VariantPreferences postgres.ColumnStringArray
|
VariantPreferences postgres.ColumnStringArray
|
||||||
|
LastLoginAt postgres.ColumnTimestampz
|
||||||
|
LastLoginIP postgres.ColumnString
|
||||||
|
DeletedAt postgres.ColumnTimestampz
|
||||||
|
DeletedDisplayName postgres.ColumnString
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -94,8 +98,12 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
|||||||
MergedAtColumn = postgres.TimestampzColumn("merged_at")
|
MergedAtColumn = postgres.TimestampzColumn("merged_at")
|
||||||
FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at")
|
FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at")
|
||||||
VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences")
|
VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences")
|
||||||
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn}
|
LastLoginAtColumn = postgres.TimestampzColumn("last_login_at")
|
||||||
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn}
|
LastLoginIPColumn = postgres.StringColumn("last_login_ip")
|
||||||
|
DeletedAtColumn = postgres.TimestampzColumn("deleted_at")
|
||||||
|
DeletedDisplayNameColumn = postgres.StringColumn("deleted_display_name")
|
||||||
|
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn}
|
||||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn}
|
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,6 +129,10 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
|||||||
MergedAt: MergedAtColumn,
|
MergedAt: MergedAtColumn,
|
||||||
FlaggedHighRateAt: FlaggedHighRateAtColumn,
|
FlaggedHighRateAt: FlaggedHighRateAtColumn,
|
||||||
VariantPreferences: VariantPreferencesColumn,
|
VariantPreferences: VariantPreferencesColumn,
|
||||||
|
LastLoginAt: LastLoginAtColumn,
|
||||||
|
LastLoginIP: LastLoginIPColumn,
|
||||||
|
DeletedAt: DeletedAtColumn,
|
||||||
|
DeletedDisplayName: DeletedDisplayNameColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var RetainedIdentities = newRetainedIdentitiesTable("backend", "retained_identities", "")
|
||||||
|
|
||||||
|
type retainedIdentitiesTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
RetainedID postgres.ColumnString
|
||||||
|
AccountID postgres.ColumnString
|
||||||
|
Kind postgres.ColumnString
|
||||||
|
ExternalID postgres.ColumnString
|
||||||
|
Confirmed postgres.ColumnBool
|
||||||
|
LinkedAt postgres.ColumnTimestampz
|
||||||
|
DetachedAt postgres.ColumnTimestampz
|
||||||
|
Reason postgres.ColumnString
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type RetainedIdentitiesTable struct {
|
||||||
|
retainedIdentitiesTable
|
||||||
|
|
||||||
|
EXCLUDED retainedIdentitiesTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new RetainedIdentitiesTable with assigned alias
|
||||||
|
func (a RetainedIdentitiesTable) AS(alias string) *RetainedIdentitiesTable {
|
||||||
|
return newRetainedIdentitiesTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new RetainedIdentitiesTable with assigned schema name
|
||||||
|
func (a RetainedIdentitiesTable) FromSchema(schemaName string) *RetainedIdentitiesTable {
|
||||||
|
return newRetainedIdentitiesTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new RetainedIdentitiesTable with assigned table prefix
|
||||||
|
func (a RetainedIdentitiesTable) WithPrefix(prefix string) *RetainedIdentitiesTable {
|
||||||
|
return newRetainedIdentitiesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new RetainedIdentitiesTable with assigned table suffix
|
||||||
|
func (a RetainedIdentitiesTable) WithSuffix(suffix string) *RetainedIdentitiesTable {
|
||||||
|
return newRetainedIdentitiesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRetainedIdentitiesTable(schemaName, tableName, alias string) *RetainedIdentitiesTable {
|
||||||
|
return &RetainedIdentitiesTable{
|
||||||
|
retainedIdentitiesTable: newRetainedIdentitiesTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newRetainedIdentitiesTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRetainedIdentitiesTableImpl(schemaName, tableName, alias string) retainedIdentitiesTable {
|
||||||
|
var (
|
||||||
|
RetainedIDColumn = postgres.StringColumn("retained_id")
|
||||||
|
AccountIDColumn = postgres.StringColumn("account_id")
|
||||||
|
KindColumn = postgres.StringColumn("kind")
|
||||||
|
ExternalIDColumn = postgres.StringColumn("external_id")
|
||||||
|
ConfirmedColumn = postgres.BoolColumn("confirmed")
|
||||||
|
LinkedAtColumn = postgres.TimestampzColumn("linked_at")
|
||||||
|
DetachedAtColumn = postgres.TimestampzColumn("detached_at")
|
||||||
|
ReasonColumn = postgres.StringColumn("reason")
|
||||||
|
allColumns = postgres.ColumnList{RetainedIDColumn, AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{ConfirmedColumn, DetachedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return retainedIdentitiesTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
RetainedID: RetainedIDColumn,
|
||||||
|
AccountID: AccountIDColumn,
|
||||||
|
Kind: KindColumn,
|
||||||
|
ExternalID: ExternalIDColumn,
|
||||||
|
Confirmed: ConfirmedColumn,
|
||||||
|
LinkedAt: LinkedAtColumn,
|
||||||
|
DetachedAt: DetachedAtColumn,
|
||||||
|
Reason: ReasonColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ func UseSchema(schema string) {
|
|||||||
GameSetupDraws = GameSetupDraws.FromSchema(schema)
|
GameSetupDraws = GameSetupDraws.FromSchema(schema)
|
||||||
Games = Games.FromSchema(schema)
|
Games = Games.FromSchema(schema)
|
||||||
Identities = Identities.FromSchema(schema)
|
Identities = Identities.FromSchema(schema)
|
||||||
|
RetainedIdentities = RetainedIdentities.FromSchema(schema)
|
||||||
Sessions = Sessions.FromSchema(schema)
|
Sessions = Sessions.FromSchema(schema)
|
||||||
SuspensionReasons = SuspensionReasons.FromSchema(schema)
|
SuspensionReasons = SuspensionReasons.FromSchema(schema)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- Account deletion as legal retention (not erasure). Two additive pieces.
|
||||||
|
--
|
||||||
|
-- retained_identities is an append-only journal of every credential detached from an
|
||||||
|
-- account — on unlink, email change, or account deletion (reason). It preserves the legal
|
||||||
|
-- dossier (which email/vk/tg was linked, and when) while the live identities row is
|
||||||
|
-- removed, so the (kind, external_id) frees for a new account to reuse. No unique
|
||||||
|
-- constraint and no kind CHECK: the same credential may recur across accounts and events,
|
||||||
|
-- and the log stays robust to future identity kinds. detached_at drives the retention TTL.
|
||||||
|
--
|
||||||
|
-- accounts gains: last_login_at / last_login_ip (stamped on a cold app-load, throttled),
|
||||||
|
-- deleted_at (the tombstone marker), and deleted_display_name (the real name retained for
|
||||||
|
-- the admin dossier after the live display_name is scrubbed to the anonymised label).
|
||||||
|
--
|
||||||
|
-- Expand-contract: everything is additive (a new table plus nullable columns), so a
|
||||||
|
-- backend image rollback stays DB-safe — older code simply ignores them. The accounts
|
||||||
|
-- table shape changes, so its generated go-jet model is regenerated.
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE backend.retained_identities (
|
||||||
|
retained_id uuid NOT NULL,
|
||||||
|
account_id uuid NOT NULL,
|
||||||
|
kind text NOT NULL,
|
||||||
|
external_id text NOT NULL,
|
||||||
|
confirmed boolean DEFAULT false NOT NULL,
|
||||||
|
linked_at timestamp with time zone NOT NULL,
|
||||||
|
detached_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
reason text NOT NULL,
|
||||||
|
CONSTRAINT retained_identities_pkey PRIMARY KEY (retained_id),
|
||||||
|
CONSTRAINT retained_identities_reason_chk
|
||||||
|
CHECK ((reason = ANY (ARRAY['unlink'::text, 'change'::text, 'delete'::text])))
|
||||||
|
);
|
||||||
|
CREATE INDEX retained_identities_account_id_idx ON backend.retained_identities (account_id);
|
||||||
|
CREATE INDEX retained_identities_detached_at_idx ON backend.retained_identities (detached_at);
|
||||||
|
|
||||||
|
ALTER TABLE backend.accounts
|
||||||
|
ADD COLUMN last_login_at timestamp with time zone,
|
||||||
|
ADD COLUMN last_login_ip text,
|
||||||
|
ADD COLUMN deleted_at timestamp with time zone,
|
||||||
|
ADD COLUMN deleted_display_name text;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE backend.accounts
|
||||||
|
DROP COLUMN last_login_at,
|
||||||
|
DROP COLUMN last_login_ip,
|
||||||
|
DROP COLUMN deleted_at,
|
||||||
|
DROP COLUMN deleted_display_name;
|
||||||
|
DROP TABLE backend.retained_identities;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Admit the 'merge' reason into retained_identities. An account merge folds a secondary
|
||||||
|
-- account into a primary; when both hold an identity of the same kind (e.g. each has a
|
||||||
|
-- confirmed email), the survivor keeps its own and the secondary's is journaled to the
|
||||||
|
-- legal dossier and removed — so the survivor never ends up with two identities of one
|
||||||
|
-- kind, and the absorbed credential is still retained. That journal row carries reason
|
||||||
|
-- 'merge', alongside the existing unlink / change / delete.
|
||||||
|
--
|
||||||
|
-- Expand-contract: the Up only WIDENS the allowed reason set, so an older backend image
|
||||||
|
-- (which writes only unlink/change/delete) still satisfies the constraint — a rollback
|
||||||
|
-- stays DB-safe. The Down narrows it again and would reject pre-existing 'merge' rows, so
|
||||||
|
-- it is a dev-only convenience, not a production rollback path (image rollback runs old
|
||||||
|
-- code against this schema, not the Down migration).
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE backend.retained_identities DROP CONSTRAINT retained_identities_reason_chk;
|
||||||
|
ALTER TABLE backend.retained_identities ADD CONSTRAINT retained_identities_reason_chk
|
||||||
|
CHECK ((reason = ANY (ARRAY['unlink'::text, 'change'::text, 'delete'::text, 'merge'::text])));
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE backend.retained_identities DROP CONSTRAINT retained_identities_reason_chk;
|
||||||
|
ALTER TABLE backend.retained_identities ADD CONSTRAINT retained_identities_reason_chk
|
||||||
|
CHECK ((reason = ANY (ARRAY['unlink'::text, 'change'::text, 'delete'::text])));
|
||||||
@@ -45,9 +45,34 @@ type bannerTimingsDTO struct {
|
|||||||
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
|
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
|
||||||
r := profileResponseFor(acc)
|
r := profileResponseFor(acc)
|
||||||
r.Banner = s.bannerFor(ctx, acc)
|
r.Banner = s.bannerFor(ctx, acc)
|
||||||
|
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fillLinkedIdentities sets the profile's confirmed email address and platform-linked
|
||||||
|
// flags from the account's identities, so the client offers the right link / unlink /
|
||||||
|
// change-email controls. A read failure leaves them zero (no controls), logged as a
|
||||||
|
// warning so the profile response still succeeds.
|
||||||
|
func (s *Server) fillLinkedIdentities(ctx context.Context, r *profileResponse, accountID uuid.UUID) {
|
||||||
|
ids, err := s.accounts.Identities(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("profile: identities read failed", zap.String("account", accountID.String()), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
switch id.Kind {
|
||||||
|
case account.KindEmail:
|
||||||
|
if id.Confirmed {
|
||||||
|
r.Email = id.ExternalID
|
||||||
|
}
|
||||||
|
case account.KindTelegram:
|
||||||
|
r.TelegramLinked = true
|
||||||
|
case account.KindVK:
|
||||||
|
r.VkLinked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// bannerFor builds the advertising-banner block for the account's profile, or
|
// bannerFor builds the advertising-banner block for the account's profile, or
|
||||||
// nil when the ads service is not configured or the viewer is not eligible to
|
// nil when the ads service is not configured or the viewer is not eligible to
|
||||||
// see a banner. The message language follows the account's bot (service)
|
// see a banner. The message language follows the account's bot (service)
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ type profileResponse struct {
|
|||||||
// see the banner (a free account with an empty hint wallet and without the
|
// see the banner (a free account with an empty hint wallet and without the
|
||||||
// no_banner role), absent otherwise. See banner.go.
|
// no_banner role), absent otherwise. See banner.go.
|
||||||
Banner *bannerDTO `json:"banner,omitempty"`
|
Banner *bannerDTO `json:"banner,omitempty"`
|
||||||
|
// Email is the account's confirmed email address ("" when none); TelegramLinked and
|
||||||
|
// VkLinked report whether a platform identity is attached. They drive the profile's
|
||||||
|
// link / unlink / change-email controls, and are filled outside the pure projection
|
||||||
|
// (they read the account's identities). See Server.profileResponse.
|
||||||
|
Email string `json:"email"`
|
||||||
|
TelegramLinked bool `json:"telegram_linked"`
|
||||||
|
VkLinked bool `json:"vk_linked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// tileDTO is one placed (or to-place) tile.
|
// tileDTO is one placed (or to-place) tile.
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/link/email/merge", s.handleLinkEmailMerge)
|
u.POST("/link/email/merge", s.handleLinkEmailMerge)
|
||||||
u.POST("/link/telegram", s.handleLinkTelegram)
|
u.POST("/link/telegram", s.handleLinkTelegram)
|
||||||
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
|
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
|
||||||
|
u.POST("/link/vk", s.handleLinkVK)
|
||||||
|
u.POST("/link/vk/merge", s.handleLinkVKMerge)
|
||||||
|
u.POST("/link/unlink", s.handleUnlink)
|
||||||
|
// Change the account's confirmed email: mail a code to the new address, then
|
||||||
|
// atomically switch on confirm (a new address owned by another account is
|
||||||
|
// refused without disclosure, never merged).
|
||||||
|
u.POST("/link/email/change/request", s.handleChangeEmailRequest)
|
||||||
|
u.POST("/link/email/change/confirm", s.handleChangeEmailConfirm)
|
||||||
|
// Account deletion (legal retention, not erasure): step-up via a mailed code
|
||||||
|
// (email accounts) or a typed phrase (platform-only), then tombstone + free creds.
|
||||||
|
u.POST("/delete/request", s.handleRequestDelete)
|
||||||
|
u.POST("/delete/confirm", s.handleConfirmDelete)
|
||||||
}
|
}
|
||||||
if s.games != nil {
|
if s.games != nil {
|
||||||
u.GET("/games", s.handleListGames)
|
u.GET("/games", s.handleListGames)
|
||||||
@@ -231,6 +243,8 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusUnprocessableEntity, "illegal_play"
|
return http.StatusUnprocessableEntity, "illegal_play"
|
||||||
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
||||||
return http.StatusConflict, "email_taken"
|
return http.StatusConflict, "email_taken"
|
||||||
|
case errors.Is(err, account.ErrLastIdentity):
|
||||||
|
return http.StatusConflict, "last_identity"
|
||||||
case errors.Is(err, accountmerge.ErrActiveGameConflict):
|
case errors.Is(err, accountmerge.ErrActiveGameConflict):
|
||||||
return http.StatusConflict, "merge_active_game_conflict"
|
return http.StatusConflict, "merge_active_game_conflict"
|
||||||
case errors.Is(err, account.ErrInvalidEmail):
|
case errors.Is(err, account.ErrInvalidEmail):
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
|
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
|
||||||
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
||||||
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
||||||
|
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
|
||||||
|
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
||||||
gm.GET("/reasons", s.consoleReasons)
|
gm.GET("/reasons", s.consoleReasons)
|
||||||
gm.POST("/reasons", s.consoleCreateReason)
|
gm.POST("/reasons", s.consoleCreateReason)
|
||||||
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
|
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
|
||||||
@@ -132,8 +134,10 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
|||||||
page := consolePage(c)
|
page := consolePage(c)
|
||||||
filter := account.UserFilter{
|
filter := account.UserFilter{
|
||||||
Robots: c.Query("kind") == "robots",
|
Robots: c.Query("kind") == "robots",
|
||||||
|
Deleted: c.Query("kind") == "deleted",
|
||||||
NameMask: c.Query("name"),
|
NameMask: c.Query("name"),
|
||||||
ExternalIDMask: c.Query("ext"),
|
ExternalIDMask: c.Query("ext"),
|
||||||
|
EmailExact: c.Query("email"),
|
||||||
}
|
}
|
||||||
total, _ := s.accounts.CountUsers(ctx, filter)
|
total, _ := s.accounts.CountUsers(ctx, filter)
|
||||||
items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
|
items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
|
||||||
@@ -145,28 +149,38 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
|||||||
if filter.Robots {
|
if filter.Robots {
|
||||||
q.Set("kind", "robots")
|
q.Set("kind", "robots")
|
||||||
}
|
}
|
||||||
|
if filter.Deleted {
|
||||||
|
q.Set("kind", "deleted")
|
||||||
|
}
|
||||||
if strings.TrimSpace(filter.NameMask) != "" {
|
if strings.TrimSpace(filter.NameMask) != "" {
|
||||||
q.Set("name", filter.NameMask)
|
q.Set("name", filter.NameMask)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(filter.ExternalIDMask) != "" {
|
if strings.TrimSpace(filter.ExternalIDMask) != "" {
|
||||||
q.Set("ext", filter.ExternalIDMask)
|
q.Set("ext", filter.ExternalIDMask)
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(filter.EmailExact) != "" {
|
||||||
|
q.Set("email", filter.EmailExact)
|
||||||
|
}
|
||||||
view := adminconsole.UsersView{
|
view := adminconsole.UsersView{
|
||||||
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
||||||
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
|
Robots: filter.Robots, Deleted: filter.Deleted, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
|
||||||
|
EmailExact: filter.EmailExact,
|
||||||
FilterQuery: template.URL(q.Encode()),
|
FilterQuery: template.URL(q.Encode()),
|
||||||
}
|
}
|
||||||
ids := make([]uuid.UUID, 0, len(items))
|
ids := make([]uuid.UUID, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
kind := "registered"
|
kind := "registered"
|
||||||
if it.IsRobot {
|
switch {
|
||||||
|
case it.IsRobot:
|
||||||
kind = "robot"
|
kind = "robot"
|
||||||
} else if it.IsGuest {
|
case it.IsDeleted:
|
||||||
|
kind = "deleted"
|
||||||
|
case it.IsGuest:
|
||||||
kind = "guest"
|
kind = "guest"
|
||||||
}
|
}
|
||||||
view.Items = append(view.Items, adminconsole.UserRow{
|
view.Items = append(view.Items, adminconsole.UserRow{
|
||||||
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
|
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
|
||||||
Language: it.PreferredLanguage, Guest: it.IsGuest,
|
Language: it.PreferredLanguage, Guest: it.IsGuest, Deleted: it.IsDeleted,
|
||||||
FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt),
|
FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt),
|
||||||
})
|
})
|
||||||
ids = append(ids, it.ID)
|
ids = append(ids, it.ID)
|
||||||
@@ -356,9 +370,31 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
if ids, err := s.accounts.Identities(ctx, id); err == nil {
|
if ids, err := s.accounts.Identities(ctx, id); err == nil {
|
||||||
for _, idn := range ids {
|
for _, idn := range ids {
|
||||||
|
if idn.Kind == account.KindEmail {
|
||||||
|
view.HasEmail = true
|
||||||
|
}
|
||||||
view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)})
|
view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if info, err := s.accounts.DeletionInfo(ctx, id); err == nil {
|
||||||
|
if info.LastLoginAt != nil {
|
||||||
|
view.LastLoginAt = fmtTime(*info.LastLoginAt)
|
||||||
|
}
|
||||||
|
view.LastLoginIP = info.LastLoginIP
|
||||||
|
if info.DeletedAt != nil {
|
||||||
|
view.Deleted = true
|
||||||
|
view.DeletedAt = fmtTime(*info.DeletedAt)
|
||||||
|
view.DeletedName = info.DeletedDisplayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rets, err := s.accounts.RetainedIdentities(ctx, id); err == nil {
|
||||||
|
for _, r := range rets {
|
||||||
|
view.Retained = append(view.Retained, adminconsole.RetainedRow{
|
||||||
|
Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
|
||||||
|
Confirmed: r.Confirmed, LinkedAt: fmtTime(r.LinkedAt), DetachedAt: fmtTime(r.DetachedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
|
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
|
||||||
view.TelegramID = tg
|
view.TelegramID = tg
|
||||||
}
|
}
|
||||||
@@ -951,6 +987,41 @@ func (s *Server) consoleGrantHints(c *gin.Context) {
|
|||||||
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back)
|
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// consoleRemoveEmail deletes the account's bound email identity (and any pending
|
||||||
|
// confirmations), freeing the address. It refuses to remove the account's only
|
||||||
|
// identity, which would leave it unreachable.
|
||||||
|
func (s *Server) consoleRemoveEmail(c *gin.Context) {
|
||||||
|
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/users/" + id.String()
|
||||||
|
switch err := s.accounts.RemoveEmailIdentity(c.Request.Context(), id); {
|
||||||
|
case errors.Is(err, account.ErrLastIdentity):
|
||||||
|
s.renderConsoleMessage(c, "Can't remove", "the email is this account's only identity — removing it would leave the account unreachable", back)
|
||||||
|
case err != nil:
|
||||||
|
s.consoleError(c, err)
|
||||||
|
default:
|
||||||
|
s.renderConsoleMessage(c, "Removed", "the email identity was erased and the address freed", back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleDeleteUser deletes an account from the console — the operator-initiated equivalent
|
||||||
|
// of the in-app deletion (legal retention, not erasure): the account is tombstoned, its
|
||||||
|
// credentials journalled + freed, its live surfaces anonymised, and its sessions revoked.
|
||||||
|
func (s *Server) consoleDeleteUser(c *gin.Context) {
|
||||||
|
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/users/" + id.String()
|
||||||
|
if err := s.deleteAccount(c.Request.Context(), id); err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderConsoleMessage(c, "Deleted", "the account was deleted: its credentials were journalled and freed, its data anonymised, and its sessions revoked", back)
|
||||||
|
}
|
||||||
|
|
||||||
// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a
|
// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a
|
||||||
// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active
|
// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active
|
||||||
// games, removing them from matchmaking. The block takes effect on the player's next request.
|
// games, removing them from matchmaking. The block takes effect on the player's next request.
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/accountdelete"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Account deletion is legal retention, not erasure (docs/ARCHITECTURE.md §9.1): the account
|
||||||
|
// row survives as a tombstone while its credentials are journalled + freed, its live
|
||||||
|
// surfaces anonymised, and its own social/ephemeral data dropped. Messages are kept. The
|
||||||
|
// step-up is a mailed code for an account with a confirmed email, or a typed phrase for a
|
||||||
|
// platform-only account (possession-proof is unattainable there, so the phrase is
|
||||||
|
// anti-impulse only).
|
||||||
|
|
||||||
|
// deletePhrase is the fixed confirmation phrase a no-email account types to delete.
|
||||||
|
// Compared case-insensitively; the client localises only the surrounding instruction.
|
||||||
|
const deletePhrase = "DELETE"
|
||||||
|
|
||||||
|
// deleteRequestResponse tells the client which step-up the account uses.
|
||||||
|
type deleteRequestResponse struct {
|
||||||
|
Method string `json:"method"` // "email" | "phrase"
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRequestDelete starts account deletion: it mails a delete code to an account with a
|
||||||
|
// confirmed email, or reports the typed-phrase path otherwise.
|
||||||
|
func (s *Server) handleRequestDelete(c *gin.Context) {
|
||||||
|
uid, ok := userID(c)
|
||||||
|
if !ok {
|
||||||
|
abortBadRequest(c, "missing identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
hasEmail, err := s.emails.HasEmail(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasEmail {
|
||||||
|
c.JSON(http.StatusOK, deleteRequestResponse{Method: "phrase"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.emails.RequestDeleteCode(ctx, uid); err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, deleteRequestResponse{Method: "email"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteConfirmBody carries the step-up proof: a mailed code (email account) or the typed
|
||||||
|
// phrase (platform-only account).
|
||||||
|
type deleteConfirmBody struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Phrase string `json:"phrase"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConfirmDelete verifies the step-up and deletes the account.
|
||||||
|
func (s *Server) handleConfirmDelete(c *gin.Context) {
|
||||||
|
uid, ok := userID(c)
|
||||||
|
if !ok {
|
||||||
|
abortBadRequest(c, "missing identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req deleteConfirmBody
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
abortBadRequest(c, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
hasEmail, err := s.emails.HasEmail(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasEmail {
|
||||||
|
if err := s.emails.VerifyDeleteCode(ctx, uid, req.Code); err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if !strings.EqualFold(strings.TrimSpace(req.Phrase), deletePhrase) {
|
||||||
|
abortBadRequest(c, "confirmation phrase does not match")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.deleteAccount(ctx, uid); err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteAccount runs the deletion orchestration after the step-up passed: it resigns the
|
||||||
|
// account's active games (so opponents are not stranded and robot games end cleanly),
|
||||||
|
// drops its all-robot games, tombstones + anonymises the account (journalling and freeing
|
||||||
|
// its credentials), and revokes its sessions. The tombstone is the point of no return —
|
||||||
|
// its failure aborts; the game cleanup and session revocation around it are best-effort.
|
||||||
|
func (s *Server) deleteAccount(ctx context.Context, uid uuid.UUID) error {
|
||||||
|
deleter := accountdelete.NewDeleter(s.db)
|
||||||
|
if s.games != nil {
|
||||||
|
if games, err := s.games.ListForAccount(ctx, uid); err == nil {
|
||||||
|
for _, g := range games {
|
||||||
|
if g.Status != game.StatusActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.games.Resign(ctx, g.ID, uid); err != nil {
|
||||||
|
s.log.Warn("delete: resign game failed", zap.String("game", g.ID.String()), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.log.Warn("delete: list games failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := deleter.DropAllRobotGames(ctx, uid); err != nil {
|
||||||
|
s.log.Warn("delete: drop all-robot games failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
if err := deleter.AnonymizeAndTombstone(ctx, uid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.sessions != nil {
|
||||||
|
if err := s.sessions.RevokeAllForAccount(ctx, uid); err != nil {
|
||||||
|
s.log.Warn("delete: revoke sessions failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +34,12 @@ type linkTelegramBody struct {
|
|||||||
ExternalID string `json:"external_id"`
|
ExternalID string `json:"external_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// linkVKBody carries a gateway-validated VK identity (the trusted vk user id the
|
||||||
|
// gateway resolved from the VK ID code exchange).
|
||||||
|
type linkVKBody struct {
|
||||||
|
ExternalID string `json:"external_id"`
|
||||||
|
}
|
||||||
|
|
||||||
// linkResultResponse is the unified result of a confirm or merge step. Status is
|
// linkResultResponse is the unified result of a confirm or merge step. Status is
|
||||||
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
|
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
|
||||||
// account — the secondary_* fields summarise it for the irreversible confirmation),
|
// account — the secondary_* fields summarise it for the irreversible confirmation),
|
||||||
@@ -66,6 +73,89 @@ func (s *Server) handleLinkEmailRequest(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unlinkBody carries the provider kind to detach.
|
||||||
|
type unlinkBody struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnlink detaches a platform identity (telegram or vk) from the caller's
|
||||||
|
// account, refusing to remove the last identity (ErrLastIdentity). Email is never
|
||||||
|
// unlinked — it is replaced through the change-email flow — so an email kind is
|
||||||
|
// rejected. It returns the refreshed profile so the client updates its controls.
|
||||||
|
func (s *Server) handleUnlink(c *gin.Context) {
|
||||||
|
uid, ok := userID(c)
|
||||||
|
if !ok {
|
||||||
|
abortBadRequest(c, "missing identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req unlinkBody
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
abortBadRequest(c, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Kind != account.KindTelegram && req.Kind != account.KindVK {
|
||||||
|
abortBadRequest(c, "only telegram or vk can be unlinked")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if err := s.accounts.RemoveIdentity(ctx, uid, req.Kind); err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acc, err := s.accounts.GetByID(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := s.profileResponse(ctx, acc)
|
||||||
|
c.JSON(http.StatusOK, linkResultResponse{Status: "unlinked", Profile: &r})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleChangeEmailRequest mails a confirm-code to a new address for an authenticated
|
||||||
|
// email change. Like the link request it never signals "taken" up front — a conflict is
|
||||||
|
// only revealed (non-disclosingly) at confirm.
|
||||||
|
func (s *Server) handleChangeEmailRequest(c *gin.Context) {
|
||||||
|
uid, ok := userID(c)
|
||||||
|
if !ok {
|
||||||
|
abortBadRequest(c, "missing identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req linkEmailRequestBody
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
abortBadRequest(c, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.emails.RequestChangeCode(c.Request.Context(), uid, req.Email); err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleChangeEmailConfirm verifies the code and atomically switches the account to the
|
||||||
|
// new address, returning the refreshed profile. A new address confirmed by another
|
||||||
|
// account is refused (ErrEmailTaken → the non-disclosing message), never merged.
|
||||||
|
func (s *Server) handleChangeEmailConfirm(c *gin.Context) {
|
||||||
|
uid, ok := userID(c)
|
||||||
|
if !ok {
|
||||||
|
abortBadRequest(c, "missing identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req linkEmailConfirmBody
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
abortBadRequest(c, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
acc, err := s.emails.ConfirmChange(ctx, uid, req.Email, req.Code)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := s.profileResponse(ctx, acc)
|
||||||
|
c.JSON(http.StatusOK, linkResultResponse{Status: "changed", Profile: &r})
|
||||||
|
}
|
||||||
|
|
||||||
// handleLinkEmailConfirm verifies the code and binds a free email or reports a
|
// handleLinkEmailConfirm verifies the code and binds a free email or reports a
|
||||||
// required merge.
|
// required merge.
|
||||||
func (s *Server) handleLinkEmailConfirm(c *gin.Context) {
|
func (s *Server) handleLinkEmailConfirm(c *gin.Context) {
|
||||||
@@ -149,6 +239,48 @@ func (s *Server) handleLinkTelegramMerge(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleLinkVK attaches a gateway-validated VK identity to the caller or reports a
|
||||||
|
// required merge.
|
||||||
|
func (s *Server) handleLinkVK(c *gin.Context) {
|
||||||
|
uid, ok := userID(c)
|
||||||
|
if !ok {
|
||||||
|
abortBadRequest(c, "missing identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req linkVKBody
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||||
|
abortBadRequest(c, "missing external_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.links.ConfirmVK(c.Request.Context(), uid, req.ExternalID)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLinkVKMerge merges the account owning a gateway-validated VK identity into
|
||||||
|
// the caller's.
|
||||||
|
func (s *Server) handleLinkVKMerge(c *gin.Context) {
|
||||||
|
uid, ok := userID(c)
|
||||||
|
if !ok {
|
||||||
|
abortBadRequest(c, "missing identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req linkVKBody
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||||
|
abortBadRequest(c, "missing external_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.links.MergeVK(c.Request.Context(), uid, req.ExternalID)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
||||||
|
}
|
||||||
|
|
||||||
// confirmResultResponse renders a confirm step: a merge preview (secondary summary)
|
// confirmResultResponse renders a confirm step: a merge preview (secondary summary)
|
||||||
// or a completed link (the active account's refreshed profile).
|
// or a completed link (the active account's refreshed profile).
|
||||||
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ func (s *Server) handleProfile(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The SPA fetches the profile once per cold app-load, so stamp the account's last
|
||||||
|
// login time and client IP here (throttled to at most once an hour). Best-effort: it
|
||||||
|
// feeds the deletion dossier, never blocks the profile read.
|
||||||
|
_ = s.accounts.StampLastLogin(c.Request.Context(), uid, clientIP(c))
|
||||||
c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc))
|
c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-5
@@ -1,6 +1,10 @@
|
|||||||
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
|
# 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
|
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; the prod
|
||||||
# deploy maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run.
|
# 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.
|
# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
|
||||||
|
|
||||||
@@ -15,8 +19,9 @@ POSTGRES_PASSWORD=change-me # required
|
|||||||
# boot the dawg-data volume preserves versions uploaded through the admin console and
|
# 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
|
# 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
|
# recorded .seed_version marker wins — the seed-drift guard); change a running
|
||||||
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
|
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). One shared Gitea
|
||||||
DICT_VERSION=v1.3.0
|
# variable (DICT_VERSION) seeds both contours + pins the CI test suite.
|
||||||
|
DICT_VERSION=v1.3.1
|
||||||
|
|
||||||
# --- Logging ----------------------------------------------------------------
|
# --- Logging ----------------------------------------------------------------
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
@@ -45,6 +50,17 @@ SMTP_RELAY_PASS= # secret
|
|||||||
SMTP_RELAY_FROM=no-reply@erudit-game.ru
|
SMTP_RELAY_FROM=no-reply@erudit-game.ru
|
||||||
PUBLIC_BASE_URL= # required when SMTP_RELAY_HOST is set (e.g. https://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 -----------------------------------------------------------
|
# --- Edge / caddy -----------------------------------------------------------
|
||||||
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
|
# 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.
|
# external `edge` network). Prod: a domain so caddy does its own ACME.
|
||||||
@@ -57,10 +73,12 @@ VITE_TELEGRAM_BOT_ID=
|
|||||||
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
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_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=
|
VITE_GATEWAY_URL=
|
||||||
|
|
||||||
# --- Grafana ----------------------------------------------------------------
|
# --- Grafana ----------------------------------------------------------------
|
||||||
GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain
|
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
|
GRAFANA_ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
# --- Telegram validator + bot -----------------------------------------------
|
# --- Telegram validator + bot -----------------------------------------------
|
||||||
@@ -77,7 +95,7 @@ TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; emp
|
|||||||
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
|
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_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_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
|
TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_BASE_URL + /telegram/
|
||||||
TELEGRAM_TEST_ENV=false
|
TELEGRAM_TEST_ENV=false
|
||||||
TELEGRAM_API_BASE_URL=
|
TELEGRAM_API_BASE_URL=
|
||||||
|
|
||||||
@@ -86,3 +104,14 @@ TELEGRAM_API_BASE_URL=
|
|||||||
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
# 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.
|
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
|
||||||
GATEWAY_VK_APP_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=
|
||||||
|
|
||||||
|
# --- 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=
|
||||||
|
|||||||
+71
-26
@@ -46,6 +46,18 @@ runs `docker compose up -d --build` on the runner host. The prod deploy maps the
|
|||||||
**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
|
**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
|
||||||
feeds the compose's `POSTGRES_PASSWORD`, etc.
|
feeds the compose's `POSTGRES_PASSWORD`, etc.
|
||||||
|
|
||||||
|
Three naming classes in Gitea:
|
||||||
|
- **Per-contour** (`TEST_`/`PROD_<NAME>`) — values that differ between the contours
|
||||||
|
(bots, hosts, public origin, log level, email senders): the common case below.
|
||||||
|
- **Shared** (one unprefixed `<NAME>`, no prefix) — values identical on every contour,
|
||||||
|
stored once: `DICT_VERSION`, `SMTP_RELAY_HOST`/`PORT`/`TLS`/`USER`/`PASS`,
|
||||||
|
`GRAFANA_SMTP_PORT`, `VITE_VK_APP_LINK`, `VITE_VK_APP_ID`, `GATEWAY_VK_APP_SECRET`,
|
||||||
|
`GATEWAY_VK_ID_CLIENT_SECRET` (one Selectel relay + one pair of VK apps for all contours).
|
||||||
|
- **Derived** — not stored at all; the deploy computes them from `PUBLIC_BASE_URL`
|
||||||
|
(`deploy/write-prod-env.sh`, and the `ci.yaml` deploy step): `TELEGRAM_MINIAPP_URL`
|
||||||
|
(`+ /telegram/`), `GRAFANA_ROOT_URL` (`+ /_gm/grafana/`), `VITE_VK_ID_REDIRECT_URL`
|
||||||
|
(`+ /app/`). Set them directly only for a local `.env` run.
|
||||||
|
|
||||||
The deploy job also **seeds the config files** (`caddy`, `otelcol`, `prometheus`,
|
The deploy job also **seeds the config files** (`caddy`, `otelcol`, `prometheus`,
|
||||||
`tempo`, `grafana`) to a stable host path (`$HOME/.scrabble-deploy`) and sets
|
`tempo`, `grafana`) to a stable host path (`$HOME/.scrabble-deploy`) and sets
|
||||||
`SCRABBLE_CONFIG_DIR` to it before `up`. The runner's checkout is an ephemeral act
|
`SCRABBLE_CONFIG_DIR` to it before `up`. The runner's checkout is an ephemeral act
|
||||||
@@ -62,7 +74,7 @@ compose binds from this directory.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
|
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
|
||||||
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
||||||
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the bot hands out in deep links / buttons. |
|
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
|
||||||
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
||||||
|
|
||||||
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
||||||
@@ -82,11 +94,11 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
||||||
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
||||||
| `DICT_VERSION` | variable | `v1.3.0` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
|
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
|
||||||
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
|
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
|
||||||
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
|
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
|
||||||
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
||||||
| `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https://<domain>/_gm/grafana/` behind a real domain. |
|
| `GRAFANA_ROOT_URL` | derived | `/_gm/grafana/` | Grafana root URL (sub-path serving). The deploy derives `PUBLIC_BASE_URL + /_gm/grafana/`; set the full `https://<domain>/_gm/grafana/` only for a local run. |
|
||||||
| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
|
| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
|
||||||
| `TELEGRAM_GAME_CHANNEL_ID` | variable | _(empty)_ | The bot's game-channel id; empty/`0` disables channel posts. |
|
| `TELEGRAM_GAME_CHANNEL_ID` | variable | _(empty)_ | The bot's game-channel id; empty/`0` disables channel posts. |
|
||||||
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
||||||
@@ -94,15 +106,26 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
||||||
| `VITE_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). |
|
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
|
||||||
|
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
|
||||||
|
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
|
||||||
|
| `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. |
|
||||||
|
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. |
|
||||||
|
| `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. |
|
||||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||||
| `SMTP_RELAY_HOST` | variable | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
||||||
| `SMTP_RELAY_PORT` | variable | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||||
| `SMTP_RELAY_TLS` | variable | _(empty)_ | Transport security: `ssl` (implicit TLS) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); required for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). |
|
| `SMTP_RELAY_TLS` | variable (shared) | _(empty)_ | Transport security: `ssl` (implicit TLS) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); required for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). |
|
||||||
| `SMTP_RELAY_USER` | secret | _(empty)_ | Relay SMTP AUTH username. |
|
| `SMTP_RELAY_USER` | secret (shared) | _(empty)_ | Relay SMTP AUTH username. |
|
||||||
| `SMTP_RELAY_PASS` | secret | _(empty)_ | Relay SMTP AUTH password. |
|
| `SMTP_RELAY_PASS` | secret (shared) | _(empty)_ | Relay SMTP AUTH password. |
|
||||||
| `SMTP_RELAY_FROM` | variable | `no-reply@localhost` | Sender address. **Must use the prod domain** (`no-reply@erudit-game.ru`) — Selectel only accepts the verified sender domain — so it is the same on every contour. |
|
| `SMTP_RELAY_FROM` | variable | `no-reply@localhost` | Sender address. **Must use the prod domain** (`no-reply@erudit-game.ru`) — Selectel only accepts the verified sender domain — so it is the same on every contour. |
|
||||||
| `PUBLIC_BASE_URL` | variable | _(empty)_ | Canonical public https origin for links in the email (the contour's own URL, e.g. `https://erudit-game.ru`). **Required by the backend whenever `SMTP_RELAY_HOST` is set** — it is never derived from a request Host header (anti-injection). |
|
| `PUBLIC_BASE_URL` | variable | _(empty)_ | Canonical public https origin for links in the email (the contour's own URL, e.g. `https://erudit-game.ru`). **Required by the backend whenever `SMTP_RELAY_HOST` is set** — it is never derived from a request Host header (anti-injection). |
|
||||||
|
| `SMTP_RELAY_ADMIN_FROM` | variable | _(empty)_ | Backend operator-alert sender (new feedback / word complaints), distinct from the confirm-code From. Empty (with `ADMIN_EMAIL`) disables the alert worker. |
|
||||||
|
| `ADMIN_EMAIL` | variable | _(empty)_ | Backend operator-alert recipient(s); several comma-separated addresses allowed. |
|
||||||
|
| `SMTP_RELAY_SERVICE_FROM` | variable | _(empty)_ | Grafana infra-alert sender address. |
|
||||||
|
| `SERVICE_EMAIL` | variable | _(empty)_ | Grafana infra-alert recipient(s); comma-separated allowed (read by the provisioned contact point via `$__env{SERVICE_EMAIL}`). |
|
||||||
|
| `GRAFANA_SMTP_PORT` | variable (shared) | _(empty)_ | STARTTLS port Grafana dials on `SMTP_RELAY_HOST` (its client can't do the backend's implicit TLS), e.g. Selectel's `1126`. Reuses `SMTP_RELAY_HOST`/`USER`/`PASS`. |
|
||||||
|
| `GF_SMTP_ENABLED` | variable | `false` | Set `true` to enable Grafana alert emails. |
|
||||||
|
|
||||||
The six `VITE_*` are **build-args** baked into the gateway and landing images at
|
The six `VITE_*` are **build-args** baked into the gateway and landing images at
|
||||||
build time (both targets share one UI build stage — keep the args identical so it is
|
build time (both targets share one UI build stage — keep the args identical so it is
|
||||||
@@ -132,14 +155,13 @@ intentionally **unset** — caddy owns `/_gm` in the contour.
|
|||||||
|
|
||||||
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
|
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
|
||||||
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
|
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
|
||||||
a build-time input with **no default** in the images, so it is set in exactly two places to
|
a build-time input with **no default** in the images. It is a single Gitea repo variable —
|
||||||
move the whole stack — change both to a new release:
|
**`DICT_VERSION`** (unprefixed, shared across contours) — so a release bump is **one edit**:
|
||||||
|
|
||||||
1. **CI tests** — `.gitea/workflows/ci.yaml` `env.DICT_VERSION` (the unit/integration jobs
|
- the CI test jobs read it via `.gitea/workflows/ci.yaml`'s top-level
|
||||||
download that dawg).
|
`env.DICT_VERSION: ${{ vars.DICT_VERSION }}` (the unit/integration jobs download that dawg), and
|
||||||
2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag
|
- both deploy jobs feed the same variable to `compose` as the `DICT_VERSION` build-arg that
|
||||||
the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as
|
bakes a **fresh** volume's seed.
|
||||||
`DICT_VERSION`).
|
|
||||||
|
|
||||||
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
|
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
|
||||||
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
|
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
|
||||||
@@ -168,6 +190,16 @@ public site. After `master` is green this workflow is the **only** thing that to
|
|||||||
prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main →
|
prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main →
|
||||||
deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
|
deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
|
||||||
|
|
||||||
|
**Maintenance page.** During the roll (and any migration window) `prod-deploy.sh` raises a
|
||||||
|
flag the edge caddy serves a static 503 "технические работы" page from
|
||||||
|
(`deploy/caddy/maintenance.html`), for the user-facing routes only — `/_gm` (Grafana) stays
|
||||||
|
reachable. The flag is cleared on any exit (success, a health failure + rollback, or an
|
||||||
|
error) by a shell trap, so it can never stick on. It arms from this feature's own deploy
|
||||||
|
onward (the caddy carrying the gate must be live first). This is **not** a zero-downtime
|
||||||
|
deploy — the backend is a single stateful instance (in-memory game state + push hub, no
|
||||||
|
Redis), so its swap still blips (clients auto-reconnect the live stream); the page just
|
||||||
|
makes the window graceful instead of raw 502s.
|
||||||
|
|
||||||
**Versioning.** Each release is a git tag `vX.Y.Z` on `master`; the deploy stamps
|
**Versioning.** Each release is a git tag `vX.Y.Z` on `master`; the deploy stamps
|
||||||
`git describe --tags` into every image tag, every binary (`-ldflags` → `pkg/version` →
|
`git describe --tags` into every image tag, every binary (`-ldflags` → `pkg/version` →
|
||||||
the `service.version` telemetry attribute) and the SPA About screen. Tag the release
|
the `service.version` telemetry attribute) and the SPA About screen. Tag the release
|
||||||
@@ -200,18 +232,31 @@ together with the fresh CA.
|
|||||||
**Sizing / monitoring:** the main host launches undersized (2 vCPU / 1.9 GiB); the prod
|
**Sizing / monitoring:** the main host launches undersized (2 vCPU / 1.9 GiB); the prod
|
||||||
overlay trims limits + `GOMAXPROCS=2` + 7d Prometheus retention, and `node_exporter` feeds
|
overlay trims limits + `GOMAXPROCS=2` + 7d Prometheus retention, and `node_exporter` feeds
|
||||||
host memory to Grafana (`/_gm/grafana/`). Watch host memory and resize at Selectel when
|
host memory to Grafana (`/_gm/grafana/`). Watch host memory and resize at Selectel when
|
||||||
players arrive.
|
players arrive. The per-container memory caps are enforced (Compose v2 → cgroup), so no one
|
||||||
|
service can eat all RAM, but they **overcommit** the host (~2.8 GiB of caps vs 1.9 GiB) — a
|
||||||
|
**1 GiB swap file** (Ansible `common` role, `swap_size`, `vm.swappiness=10`) cushions a
|
||||||
|
simultaneous spike into swap instead of the kernel OOM-killer. The `host_mem_low` Grafana
|
||||||
|
alert fires under 10% available.
|
||||||
|
|
||||||
**`PROD_` Gitea set** (mirrors `TEST_`, mapped onto the unprefixed names above) — secrets:
|
**Shared Gitea set** (one unprefixed entry each, used by every contour) — secrets:
|
||||||
|
`GATEWAY_VK_APP_SECRET, GATEWAY_VK_ID_CLIENT_SECRET, SMTP_RELAY_USER, SMTP_RELAY_PASS`;
|
||||||
|
variables: `DICT_VERSION, SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, GRAFANA_SMTP_PORT,
|
||||||
|
VITE_VK_APP_LINK, VITE_VK_APP_ID`. **Derived from `PUBLIC_BASE_URL` at deploy** (not stored):
|
||||||
|
`TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL, VITE_VK_ID_REDIRECT_URL`.
|
||||||
|
|
||||||
|
**`PROD_` Gitea set** (per-contour, mirrors `TEST_`, mapped onto the unprefixed names above)
|
||||||
|
— secrets:
|
||||||
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
||||||
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
|
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, GATEWAY_HONEYTOKEN, REGISTRY_PASSWORD, SSH_KEY,
|
||||||
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY,
|
SSH_KNOWN_HOSTS, BOTLINK_CA, BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT,
|
||||||
SMTP_RELAY_USER, SMTP_RELAY_PASS}`; variables:
|
BOTLINK_BOT_KEY}`; variables:
|
||||||
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
|
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, LOG_LEVEL,
|
||||||
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
|
TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_BOT_USERNAME,
|
||||||
TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
|
VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, VITE_TELEGRAM_GAME_CHANNEL_NAME, SMTP_RELAY_FROM,
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK,
|
PUBLIC_BASE_URL, SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL,
|
||||||
SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, SMTP_RELAY_FROM, PUBLIC_BASE_URL}`.
|
GF_SMTP_ENABLED}`. The test contour uses the same names under `TEST_`, minus the prod-only
|
||||||
|
infra (`MAIN_HOST`/`TG_HOST`/`REGISTRY_*`/`SSH_*`/`BOTLINK_*`, which the test deploy generates
|
||||||
|
or runs locally) and plus `TEST_AWG_CONF` (the bot's VPN egress).
|
||||||
|
|
||||||
## Host-side setup (outside this repo)
|
## Host-side setup (outside this repo)
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,11 @@ scrabble_base_dir: /opt/scrabble
|
|||||||
# the host's own containers (and any ad-hoc runs) rotate identically.
|
# the host's own containers (and any ad-hoc runs) rotate identically.
|
||||||
docker_log_max_size: "10m"
|
docker_log_max_size: "10m"
|
||||||
docker_log_max_file: "3"
|
docker_log_max_file: "3"
|
||||||
|
|
||||||
|
# Swap file as an OOM cushion. The per-container memory caps (docker-compose.prod.yml)
|
||||||
|
# sum to more than the tight main host's RAM (2 vCPU / 1.9 GiB), so a simultaneous
|
||||||
|
# spike could otherwise hit the kernel OOM-killer and it might pick postgres. A small
|
||||||
|
# swap absorbs the overshoot; swappiness stays low so swap is a cushion, not a hot
|
||||||
|
# path (a slow service beats a killed one). Set swap_size to "0" to skip provisioning.
|
||||||
|
swap_size: "1G"
|
||||||
|
swap_swappiness: 10
|
||||||
|
|||||||
@@ -150,6 +150,57 @@
|
|||||||
enabled: true
|
enabled: true
|
||||||
state: started
|
state: started
|
||||||
|
|
||||||
|
# --- Swap file (OOM cushion) ---------------------------------------------------
|
||||||
|
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
||||||
|
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
||||||
|
# the kernel OOM-killer (which might pick postgres). A small swap absorbs the
|
||||||
|
# overshoot. Idempotent; skipped when swap_size == "0". Builtin-only (no ansible.posix).
|
||||||
|
|
||||||
|
- name: Check whether the swap file is already active
|
||||||
|
ansible.builtin.command: swapon --show=NAME --noheadings
|
||||||
|
register: swap_active
|
||||||
|
changed_when: false
|
||||||
|
when: swap_size != "0"
|
||||||
|
|
||||||
|
- name: Allocate the swap file
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: "fallocate -l {{ swap_size }} /swapfile"
|
||||||
|
creates: /swapfile
|
||||||
|
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
|
||||||
|
|
||||||
|
- name: Secure the swap file (0600)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /swapfile
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
when: swap_size != "0"
|
||||||
|
|
||||||
|
- name: Format and enable the swap file
|
||||||
|
ansible.builtin.shell: "mkswap /swapfile && swapon /swapfile"
|
||||||
|
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
|
||||||
|
|
||||||
|
- name: Persist the swap file in /etc/fstab
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/fstab
|
||||||
|
line: "/swapfile none swap sw 0 0"
|
||||||
|
regexp: '^/swapfile\s'
|
||||||
|
state: present
|
||||||
|
when: swap_size != "0"
|
||||||
|
|
||||||
|
- name: Keep swap a cushion, not a hot path (low swappiness)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/sysctl.d/60-scrabble-swap.conf
|
||||||
|
mode: "0644"
|
||||||
|
content: "vm.swappiness = {{ swap_swappiness }}\n"
|
||||||
|
register: swappiness_conf
|
||||||
|
when: swap_size != "0"
|
||||||
|
|
||||||
|
- name: Apply swappiness now
|
||||||
|
ansible.builtin.command: sysctl --system
|
||||||
|
changed_when: false
|
||||||
|
when: swap_size != "0" and swappiness_conf is changed
|
||||||
|
|
||||||
# --- Deploy directories --------------------------------------------------------
|
# --- Deploy directories --------------------------------------------------------
|
||||||
|
|
||||||
- name: Create the scrabble base directories
|
- name: Create the scrabble base directories
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# blackbox_exporter probe modules. tls_cert opens a verified TLS connection to the edge
|
||||||
|
# caddy so Prometheus can read probe_ssl_earliest_cert_expiry — the signal behind the
|
||||||
|
# certificate-renewal-failure alert. The SNI is the production public host, whose cert the
|
||||||
|
# edge caddy serves once it does its own ACME; on the test contour caddy is HTTP-only
|
||||||
|
# (behind the host caddy), so the probe finds nothing on :443 and the cert metric is absent.
|
||||||
|
modules:
|
||||||
|
tls_cert:
|
||||||
|
prober: tcp
|
||||||
|
timeout: 5s
|
||||||
|
tcp:
|
||||||
|
tls: true
|
||||||
|
tls_config:
|
||||||
|
server_name: erudit-game.ru
|
||||||
|
insecure_skip_verify: false
|
||||||
@@ -33,6 +33,39 @@
|
|||||||
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
|
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
|
||||||
header Alt-Svc clear
|
header Alt-Svc clear
|
||||||
|
|
||||||
|
# Maintenance gate. While the deploy holds the flag file /srv/maint/on (touched by
|
||||||
|
# prod-deploy.sh around a rolling swap, removed on the script's exit), serve a static
|
||||||
|
# 503 "works in progress" page instead of proxying to a mid-recreate upstream —
|
||||||
|
# a graceful signal rather than raw 502s. Operator surfaces (/_gm) are excluded so
|
||||||
|
# Grafana stays reachable during the window. Caddy stat()s the flag per request, so
|
||||||
|
# toggling it needs no reload; the page + flag live in the caddy config dir bind-mounted
|
||||||
|
# read-only at /srv/maint (docker-compose.yml). The test contour never sets the flag
|
||||||
|
# (only prod-deploy.sh does), so this is inert there.
|
||||||
|
@maintenance {
|
||||||
|
not path /_gm /_gm/*
|
||||||
|
file {
|
||||||
|
root /srv/maint
|
||||||
|
try_files on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle @maintenance {
|
||||||
|
error 503
|
||||||
|
}
|
||||||
|
handle_errors {
|
||||||
|
@maint503 expression {err.status_code} == 503
|
||||||
|
handle @maint503 {
|
||||||
|
root * /srv/maint
|
||||||
|
rewrite * /maintenance.html
|
||||||
|
header Retry-After 120
|
||||||
|
# Distinctive maintenance marker so the SPA can tell a planned window apart from
|
||||||
|
# a transient upstream 503 and show its own dimmed overlay (an in-session user
|
||||||
|
# never sees this static page — it only renders on a fresh load). The header
|
||||||
|
# rides every gated response, incl. the Connect/gRPC edge the app polls.
|
||||||
|
header X-Scrabble-Maintenance 1
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Operator surfaces under /_gm: a single shared Basic-Auth, then route.
|
# Operator surfaces under /_gm: a single shared Basic-Auth, then route.
|
||||||
@gm path /_gm /_gm/*
|
@gm path /_gm /_gm/*
|
||||||
handle @gm {
|
handle @gm {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!--
|
||||||
|
Served with 503 by the edge caddy while the deploy holds the maintenance flag
|
||||||
|
(/srv/maint/on, toggled by deploy/prod-deploy.sh around a rolling swap). Static and
|
||||||
|
self-contained (no upstream, no external assets) so it renders while the backend /
|
||||||
|
gateway are mid-recreate.
|
||||||
|
-->
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>Эрудит — технические работы</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
html, body { height: 100%; margin: 0; }
|
||||||
|
body {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #f4f1ea; color: #2b2b2b;
|
||||||
|
padding: 24px; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) { body { background: #1d1b17; color: #e8e4da; } }
|
||||||
|
.card { max-width: 30rem; text-align: center; line-height: 1.5; }
|
||||||
|
.tile {
|
||||||
|
display: inline-block; width: 3.5rem; height: 3.5rem; line-height: 3.5rem;
|
||||||
|
font-size: 2rem; font-weight: 700; border-radius: 10px;
|
||||||
|
background: #d9b451; color: #2b2b2b; box-shadow: 0 2px 4px rgba(0,0,0,.25);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.4rem; margin: 0 0 .5rem; }
|
||||||
|
p { margin: .35rem 0; }
|
||||||
|
.en { opacity: .7; font-size: .95rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<div class="tile">Э</div>
|
||||||
|
<h1>Технические работы</h1>
|
||||||
|
<p>Идёт короткое обновление игры. Мы скоро вернёмся — обновите страницу через пару минут.</p>
|
||||||
|
<p class="en">Scrabble is briefly down for an update. Please refresh in a couple of minutes.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -151,6 +151,10 @@ services:
|
|||||||
BACKEND_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-}
|
BACKEND_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-}
|
||||||
BACKEND_SMTP_FROM: ${SMTP_RELAY_FROM:-no-reply@localhost}
|
BACKEND_SMTP_FROM: ${SMTP_RELAY_FROM:-no-reply@localhost}
|
||||||
BACKEND_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
|
BACKEND_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
|
||||||
|
# Operator alert emails (new feedback / word complaints): a distinct sender and the
|
||||||
|
# recipient(s) (comma-separated allowed). Both empty disables the alert worker.
|
||||||
|
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
|
||||||
|
BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||||
# The dictionary lives on a named volume seeded from the image on first boot
|
# The dictionary lives on a named volume seeded from the image on first boot
|
||||||
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
|
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
|
||||||
# inherits). The admin console writes new version subdirectories here, and the
|
# inherits). The admin console writes new version subdirectories here, and the
|
||||||
@@ -186,6 +190,8 @@ services:
|
|||||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
||||||
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
||||||
|
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
|
||||||
|
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
||||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||||
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
|
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
|
||||||
@@ -203,6 +209,14 @@ services:
|
|||||||
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
|
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
|
||||||
# the VK auth path (auth.vk is then unregistered).
|
# the VK auth path (auth.vk is then unregistered).
|
||||||
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
|
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
|
||||||
|
# VK ID web login (browser VK-identity linking): the confidential OAuth 2.1 code
|
||||||
|
# exchange runs server-side under the VK ID "Web" app's protected key. This is a
|
||||||
|
# SEPARATE VK app from the Mini App above, so the credentials are distinct. The app id
|
||||||
|
# and redirect URL are the same values the SPA builds its authorize URL from (one
|
||||||
|
# source each). All three empty disables the link.vk.* ops.
|
||||||
|
GATEWAY_VK_ID_APP_ID: ${VITE_VK_APP_ID:-}
|
||||||
|
GATEWAY_VK_ID_CLIENT_SECRET: ${GATEWAY_VK_ID_CLIENT_SECRET:-}
|
||||||
|
GATEWAY_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
||||||
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
|
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
|
||||||
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
|
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
|
||||||
# listeners stay on the internal network (the bot shares the VPN netns); in prod
|
# listeners stay on the internal network (the bot shares the VPN netns); in prod
|
||||||
@@ -213,10 +227,12 @@ services:
|
|||||||
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
|
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
|
||||||
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
|
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
|
||||||
# Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the
|
# Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the
|
||||||
# honeypot/honeytoken. Off by default: it bans by client IP, which is only
|
# honeypot/honeytoken. Off by default: it bans by client IP, which is only real
|
||||||
# real in prod — the test contour arrives as one shared NAT address, so a ban
|
# in prod — the test contour arrives as one shared NAT address, so a ban there
|
||||||
# there would be self-inflicted (the honeypot/honeytoken still log). Prod sets
|
# would be self-inflicted (the honeypot still logs). The prod deploy forces it on
|
||||||
# these from PROD_ inputs; GATEWAY_HONEYTOKEN is the planted bearer trap.
|
# in env.sh (deploy/write-prod-env.sh), not via a Gitea variable. GATEWAY_HONEYTOKEN
|
||||||
|
# is the planted bearer trap, fed from the per-contour TEST_/PROD_GATEWAY_HONEYTOKEN
|
||||||
|
# secret; empty (unset secret) leaves the trap off.
|
||||||
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
||||||
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
||||||
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
@@ -260,6 +276,8 @@ services:
|
|||||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
||||||
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
||||||
|
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
|
||||||
|
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
||||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -410,6 +428,11 @@ services:
|
|||||||
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
|
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
|
||||||
volumes:
|
volumes:
|
||||||
- ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
- ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
# Maintenance page + toggle flag: the caddy config dir holds maintenance.html and the
|
||||||
|
# `on` flag prod-deploy.sh touches around a rolling swap; the Caddyfile serves a 503
|
||||||
|
# from here while the flag exists (read-only mount — the deploy writes the flag on the
|
||||||
|
# host side). See deploy/caddy/Caddyfile and deploy/prod-deploy.sh.
|
||||||
|
- ${SCRABBLE_CONFIG_DIR:-.}/caddy:/srv/maint:ro
|
||||||
- caddy-data:/data
|
- caddy-data:/data
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
@@ -497,6 +520,21 @@ services:
|
|||||||
# caddy's Basic-Auth and re-prompts for the password on every dashboard; the
|
# caddy's Basic-Auth and re-prompts for the password on every dashboard; the
|
||||||
# dashboards poll and do not need Live.
|
# dashboards poll and do not need Live.
|
||||||
GF_LIVE_MAX_CONNECTIONS: "0"
|
GF_LIVE_MAX_CONNECTIONS: "0"
|
||||||
|
# SMTP for alert emails, reusing the shared relay host + credentials + the SERVICE
|
||||||
|
# From/recipient. Grafana's client speaks STARTTLS (not the backend's implicit-TLS
|
||||||
|
# port), so it dials the relay host on its STARTTLS port (GRAFANA_SMTP_PORT).
|
||||||
|
# Disabled unless GF_SMTP_ENABLED.
|
||||||
|
GF_SMTP_ENABLED: ${GF_SMTP_ENABLED:-false}
|
||||||
|
GF_SMTP_HOST: ${SMTP_RELAY_HOST:-}:${GRAFANA_SMTP_PORT:-}
|
||||||
|
GF_SMTP_USER: ${SMTP_RELAY_USER:-}
|
||||||
|
GF_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-}
|
||||||
|
# A BARE address (Grafana rejects the "Name" <addr> form); the deploy derives it from
|
||||||
|
# SMTP_RELAY_SERVICE_FROM, splitting off the display name into GRAFANA_SMTP_FROM_NAME.
|
||||||
|
GF_SMTP_FROM_ADDRESS: ${GRAFANA_SMTP_FROM_ADDRESS:-}
|
||||||
|
GF_SMTP_FROM_NAME: ${GRAFANA_SMTP_FROM_NAME:-Erudit Alerts}
|
||||||
|
GF_SMTP_STARTTLS_POLICY: MandatoryStartTLS
|
||||||
|
# The alert recipient(s), read by the provisioned contact point via $__env{SERVICE_EMAIL}.
|
||||||
|
SERVICE_EMAIL: ${SERVICE_EMAIL:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro
|
- ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
# Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the
|
# Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the
|
||||||
@@ -547,6 +585,24 @@ services:
|
|||||||
memory: 64M
|
memory: 64M
|
||||||
networks: [internal]
|
networks: [internal]
|
||||||
|
|
||||||
|
# blackbox_exporter lets Prometheus alert on TLS certificate expiry (a Caddy ACME
|
||||||
|
# renewal failure) via probe_ssl_earliest_cert_expiry. It probes the edge caddy that
|
||||||
|
# terminates TLS (prod: the published scrabble-caddy; the test contour has no compose
|
||||||
|
# caddy, so the probe simply finds no target and the cert metric is absent — the rule is
|
||||||
|
# absent-safe). See prometheus.yml and grafana alerting rules.
|
||||||
|
blackbox_exporter:
|
||||||
|
container_name: scrabble-blackbox-exporter
|
||||||
|
image: prom/blackbox-exporter:v0.25.0
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *default-logging
|
||||||
|
volumes:
|
||||||
|
- ${SCRABBLE_CONFIG_DIR:-.}/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 64M
|
||||||
|
networks: [internal, edge]
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
internal:
|
internal:
|
||||||
name: scrabble-internal
|
name: scrabble-internal
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Grafana alerting contact point: the operator's alert mailbox, read from the
|
||||||
|
# SERVICE_EMAIL container env (see docker-compose.yml grafana), so it stays per-contour.
|
||||||
|
# SERVICE_EMAIL may hold several comma-separated addresses.
|
||||||
|
apiVersion: 1
|
||||||
|
contactPoints:
|
||||||
|
- orgId: 1
|
||||||
|
name: ops-email
|
||||||
|
receivers:
|
||||||
|
- uid: ops_email
|
||||||
|
type: email
|
||||||
|
settings:
|
||||||
|
addresses: $__env{SERVICE_EMAIL}
|
||||||
|
singleEmail: true
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Notification policy: every alert routes to the operator email, grouped so a burst is one
|
||||||
|
# message, with a 4-hour re-notify while still firing.
|
||||||
|
apiVersion: 1
|
||||||
|
policies:
|
||||||
|
- orgId: 1
|
||||||
|
receiver: ops-email
|
||||||
|
group_by: ['alertname']
|
||||||
|
group_wait: 30s
|
||||||
|
group_interval: 5m
|
||||||
|
repeat_interval: 4h
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# Grafana provisioned alert rules for the Scrabble contour. Each rule is a Prometheus
|
||||||
|
# instant query (refId A) fed into a threshold expression (refId C). noDataState/execErrState
|
||||||
|
# are OK so an absent metric never raises a false alert — notably cert-expiry, whose blackbox
|
||||||
|
# probe has no target on the test contour (caddy is HTTP-only there). Metric names are the
|
||||||
|
# real ones Prometheus exposes (edge_request_* from the gateway via the collector,
|
||||||
|
# node_*/pg_*/probe_ssl_* from the exporters). All route to the ops-email contact point.
|
||||||
|
apiVersion: 1
|
||||||
|
groups:
|
||||||
|
- orgId: 1
|
||||||
|
name: scrabble-service
|
||||||
|
folder: Alerts
|
||||||
|
interval: 1m
|
||||||
|
rules:
|
||||||
|
- uid: svc_target_down
|
||||||
|
title: Scrape target down
|
||||||
|
condition: C
|
||||||
|
for: 3m
|
||||||
|
noDataState: OK
|
||||||
|
execErrState: OK
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange: { from: 300, to: 0 }
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model: { refId: A, expr: up, instant: true }
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
expression: A
|
||||||
|
conditions:
|
||||||
|
- evaluator: { type: lt, params: [1] }
|
||||||
|
labels: { severity: critical }
|
||||||
|
annotations: { summary: 'A Prometheus scrape target is down (up < 1).' }
|
||||||
|
|
||||||
|
- uid: edge_error_rate
|
||||||
|
title: Gateway internal-error rate high
|
||||||
|
condition: C
|
||||||
|
for: 10m
|
||||||
|
noDataState: OK
|
||||||
|
execErrState: OK
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange: { from: 600, to: 0 }
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
refId: A
|
||||||
|
expr: sum by (service_name) (rate(edge_request_duration_count{result="internal"}[5m]))
|
||||||
|
instant: true
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
expression: A
|
||||||
|
conditions:
|
||||||
|
- evaluator: { type: gt, params: [0.05] }
|
||||||
|
labels: { severity: warning }
|
||||||
|
annotations: { summary: 'Sustained internal (5xx-equivalent) errors at the edge.' }
|
||||||
|
|
||||||
|
- uid: edge_latency_p99
|
||||||
|
title: Gateway request latency p99 high
|
||||||
|
condition: C
|
||||||
|
for: 10m
|
||||||
|
noDataState: OK
|
||||||
|
execErrState: OK
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange: { from: 600, to: 0 }
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
refId: A
|
||||||
|
expr: histogram_quantile(0.99, sum by (le, service_name) (rate(edge_request_duration_bucket[5m])))
|
||||||
|
instant: true
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
expression: A
|
||||||
|
conditions:
|
||||||
|
- evaluator: { type: gt, params: [1] }
|
||||||
|
labels: { severity: warning }
|
||||||
|
annotations: { summary: 'Edge request p99 latency above 1s.' }
|
||||||
|
|
||||||
|
- uid: tls_cert_expiry
|
||||||
|
title: TLS certificate nearing expiry
|
||||||
|
condition: C
|
||||||
|
for: 15m
|
||||||
|
noDataState: OK
|
||||||
|
execErrState: OK
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange: { from: 300, to: 0 }
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
refId: A
|
||||||
|
expr: (probe_ssl_earliest_cert_expiry - time()) / 86400
|
||||||
|
instant: true
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
expression: A
|
||||||
|
conditions:
|
||||||
|
- evaluator: { type: lt, params: [20] }
|
||||||
|
labels: { severity: critical }
|
||||||
|
annotations: { summary: 'Edge TLS cert has under 20 days left — Caddy ACME renewal may have failed.' }
|
||||||
|
|
||||||
|
- orgId: 1
|
||||||
|
name: scrabble-host
|
||||||
|
folder: Alerts
|
||||||
|
interval: 1m
|
||||||
|
rules:
|
||||||
|
- uid: host_mem_low
|
||||||
|
title: Host memory low
|
||||||
|
condition: C
|
||||||
|
for: 5m
|
||||||
|
noDataState: OK
|
||||||
|
execErrState: OK
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange: { from: 300, to: 0 }
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
refId: A
|
||||||
|
expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
|
||||||
|
instant: true
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
expression: A
|
||||||
|
conditions:
|
||||||
|
- evaluator: { type: lt, params: [0.1] }
|
||||||
|
labels: { severity: critical }
|
||||||
|
annotations: { summary: 'Under 10% host memory available.' }
|
||||||
|
|
||||||
|
- uid: host_disk_low
|
||||||
|
title: Host disk low
|
||||||
|
condition: C
|
||||||
|
for: 10m
|
||||||
|
noDataState: OK
|
||||||
|
execErrState: OK
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange: { from: 300, to: 0 }
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
refId: A
|
||||||
|
expr: min(node_filesystem_avail_bytes / node_filesystem_size_bytes)
|
||||||
|
instant: true
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
expression: A
|
||||||
|
conditions:
|
||||||
|
- evaluator: { type: lt, params: [0.1] }
|
||||||
|
labels: { severity: critical }
|
||||||
|
annotations: { summary: 'A host filesystem is under 10% free.' }
|
||||||
|
|
||||||
|
- uid: host_cpu_high
|
||||||
|
title: Host CPU saturated
|
||||||
|
condition: C
|
||||||
|
for: 10m
|
||||||
|
noDataState: OK
|
||||||
|
execErrState: OK
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange: { from: 600, to: 0 }
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
refId: A
|
||||||
|
expr: 1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m]))
|
||||||
|
instant: true
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
expression: A
|
||||||
|
conditions:
|
||||||
|
- evaluator: { type: gt, params: [0.9] }
|
||||||
|
labels: { severity: warning }
|
||||||
|
annotations: { summary: 'Host CPU above 90% for 10 minutes.' }
|
||||||
|
|
||||||
|
- uid: pg_connections_high
|
||||||
|
title: Postgres connections high
|
||||||
|
condition: C
|
||||||
|
for: 5m
|
||||||
|
noDataState: OK
|
||||||
|
execErrState: OK
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange: { from: 300, to: 0 }
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
refId: A
|
||||||
|
expr: sum(pg_stat_activity_count) / max(pg_settings_max_connections)
|
||||||
|
instant: true
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
expression: A
|
||||||
|
conditions:
|
||||||
|
- evaluator: { type: gt, params: [0.8] }
|
||||||
|
labels: { severity: warning }
|
||||||
|
annotations: { summary: 'Postgres using over 80% of max_connections.' }
|
||||||
@@ -42,6 +42,15 @@ PREV_STATE_FILE="${PREV_STATE_FILE:-/opt/scrabble/PREVIOUS_TAG}"
|
|||||||
PG_USER="${POSTGRES_USER:-scrabble}"
|
PG_USER="${POSTGRES_USER:-scrabble}"
|
||||||
PG_DB="${POSTGRES_DB:-scrabble}"
|
PG_DB="${POSTGRES_DB:-scrabble}"
|
||||||
|
|
||||||
|
# Edge maintenance page. caddy serves a static 503 "works in progress" page for the
|
||||||
|
# user-facing routes while this flag file exists (deploy/caddy/Caddyfile). We hold it
|
||||||
|
# across the roll / migration window and clear it on ANY exit — success, a health
|
||||||
|
# failure + rollback, or an unexpected error — via the trap, so users get a graceful
|
||||||
|
# page instead of raw mid-swap 502s and the flag can never get stuck on.
|
||||||
|
MAINT_FLAG="${MAINT_FLAG:-${SCRABBLE_CONFIG_DIR:-/opt/scrabble}/caddy/on}"
|
||||||
|
maint_off() { rm -f "$MAINT_FLAG" 2>/dev/null || true; }
|
||||||
|
trap maint_off EXIT
|
||||||
|
|
||||||
cd "$COMPOSE_DIR" || { echo "compose dir $COMPOSE_DIR missing"; exit 1; }
|
cd "$COMPOSE_DIR" || { echo "compose dir $COMPOSE_DIR missing"; exit 1; }
|
||||||
export REGISTRY
|
export REGISTRY
|
||||||
# otelcol joins the host docker group to read the socket; the GID varies per host.
|
# otelcol joins the host docker group to read the socket; the GID varies per host.
|
||||||
@@ -119,6 +128,13 @@ if [ -z "$(docker ps -aq -f name=scrabble-backend)" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Existing stack: raise the maintenance page for the whole roll (and any migration
|
||||||
|
# window). It shows once caddy carries the gate (from this feature's own deploy
|
||||||
|
# onward); the EXIT trap lowers it however this run ends.
|
||||||
|
mkdir -p "$(dirname "$MAINT_FLAG")"
|
||||||
|
: > "$MAINT_FLAG"
|
||||||
|
echo "maintenance page raised ($MAINT_FLAG)"
|
||||||
|
|
||||||
# Migration deploy: freeze writes and snapshot a consistent dump before migrating.
|
# Migration deploy: freeze writes and snapshot a consistent dump before migrating.
|
||||||
if [ "$MIGRATION" = 1 ]; then
|
if [ "$MIGRATION" = 1 ]; then
|
||||||
echo "migration deploy: opening maintenance window (stopping the backend = the only writer)"
|
echo "migration deploy: opening maintenance window (stopping the backend = the only writer)"
|
||||||
|
|||||||
@@ -23,3 +23,21 @@ scrape_configs:
|
|||||||
- job_name: node
|
- job_name: node
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ["node_exporter:9100"]
|
- targets: ["node_exporter:9100"]
|
||||||
|
# TLS certificate expiry of the edge caddy (probe_ssl_earliest_cert_expiry), for the
|
||||||
|
# certificate-renewal-failure alert. Effective on prod, where caddy terminates TLS on
|
||||||
|
# :443; on the test contour caddy is HTTP-only, so the probe finds nothing and the metric
|
||||||
|
# is absent (the alert rule is absent-safe). The exporter probes the target passed as a
|
||||||
|
# scrape parameter and answers on its own :9115.
|
||||||
|
- job_name: blackbox_tls
|
||||||
|
metrics_path: /probe
|
||||||
|
params:
|
||||||
|
module: [tls_cert]
|
||||||
|
static_configs:
|
||||||
|
- targets: ["caddy:443"]
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels: [__address__]
|
||||||
|
target_label: __param_target
|
||||||
|
- source_labels: [__param_target]
|
||||||
|
target_label: instance
|
||||||
|
- target_label: __address__
|
||||||
|
replacement: blackbox_exporter:9115
|
||||||
|
|||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Render the prod bot-host env.bot.sh from the workflow job environment.
|
||||||
|
#
|
||||||
|
# Sourced identically by prod-deploy (deploy-bot) and prod-rollback
|
||||||
|
# (rollback-bot) so the two paths cannot drift (see deploy/write-prod-env.sh
|
||||||
|
# for the same rationale on the main host).
|
||||||
|
#
|
||||||
|
# Usage: BOT_IMAGE=<image ref> bash deploy/write-prod-bot-env.sh <out-path>
|
||||||
|
#
|
||||||
|
# Every other value comes from the caller's environment (the job `env:` block).
|
||||||
|
out="${1:?usage: write-prod-bot-env.sh <out-path>}"
|
||||||
|
|
||||||
|
# The bot's Mini App URL is the same public origin the SPA serves; derive it
|
||||||
|
# rather than storing a second copy.
|
||||||
|
base="${PUBLIC_BASE_URL%/}"
|
||||||
|
TELEGRAM_MINIAPP_URL="$base/telegram/"
|
||||||
|
|
||||||
|
cat > "$out" <<EOF
|
||||||
|
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
||||||
|
export BOT_IMAGE='$BOT_IMAGE'
|
||||||
|
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
|
||||||
|
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
|
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
|
||||||
|
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
|
||||||
|
export TELEGRAM_SUPPORT_CHAT_ID='$TELEGRAM_SUPPORT_CHAT_ID'
|
||||||
|
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
|
||||||
|
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
|
||||||
|
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
|
||||||
|
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
||||||
|
EOF
|
||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Render the prod main-host runtime env.sh from the workflow job environment.
|
||||||
|
#
|
||||||
|
# Sourced identically by prod-deploy (deploy-main) and prod-rollback
|
||||||
|
# (rollback-main) so the two paths cannot drift: a rollback recreates the
|
||||||
|
# containers, so it must re-render the SAME runtime env a full deploy does —
|
||||||
|
# otherwise transactional email, VK login and Grafana alerts silently go dark
|
||||||
|
# after a rollback until the next full deploy.
|
||||||
|
#
|
||||||
|
# Usage: APP_VERSION=<tag> bash deploy/write-prod-env.sh <out-path>
|
||||||
|
#
|
||||||
|
# Every other value comes from the caller's environment (the job `env:` block,
|
||||||
|
# vars.* / secrets.*). Mirrors the compose interpolation contract in
|
||||||
|
# deploy/.env.example; keep in sync with deploy/docker-compose{,.prod}.yml.
|
||||||
|
out="${1:?usage: write-prod-env.sh <out-path>}"
|
||||||
|
|
||||||
|
# Derive the public URLs from the one canonical origin instead of storing each
|
||||||
|
# under its own variable. The path suffixes are structural (SPA routes / the
|
||||||
|
# Caddy /_gm sub-path), identical on every contour.
|
||||||
|
base="${PUBLIC_BASE_URL%/}"
|
||||||
|
TELEGRAM_MINIAPP_URL="$base/telegram/"
|
||||||
|
GRAFANA_ROOT_URL="$base/_gm/grafana/"
|
||||||
|
VITE_VK_ID_REDIRECT_URL="$base/app/"
|
||||||
|
|
||||||
|
# Grafana needs a BARE from-address (it rejects the "Name" <addr> form the backend
|
||||||
|
# go-mail accepts, and validates it even when SMTP is disabled — a bad value
|
||||||
|
# crash-loops Grafana). Split the display-format SERVICE From into address + name;
|
||||||
|
# the backend keeps the full form.
|
||||||
|
svc_from="${SMTP_RELAY_SERVICE_FROM:-}"
|
||||||
|
GRAFANA_SMTP_FROM_NAME=''
|
||||||
|
case "$svc_from" in
|
||||||
|
*"<"*">"*)
|
||||||
|
GRAFANA_SMTP_FROM_ADDRESS="$(printf '%s' "$svc_from" | sed -E 's/.*<([^>]+)>.*/\1/')"
|
||||||
|
GRAFANA_SMTP_FROM_NAME="$(printf '%s' "$svc_from" | sed -E 's/[[:space:]]*<[^>]*>.*$//; s/^"//; s/"$//')" ;;
|
||||||
|
*) GRAFANA_SMTP_FROM_ADDRESS="$svc_from" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
cat > "$out" <<EOF
|
||||||
|
export REGISTRY='$REGISTRY'
|
||||||
|
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
||||||
|
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
|
||||||
|
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
|
||||||
|
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
|
||||||
|
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
|
||||||
|
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
|
||||||
|
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
|
||||||
|
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
|
||||||
|
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
|
||||||
|
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
||||||
|
export DICT_VERSION='$DICT_VERSION'
|
||||||
|
export APP_VERSION='$APP_VERSION'
|
||||||
|
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
|
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||||
|
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
|
||||||
|
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
|
||||||
|
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
|
||||||
|
export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY'
|
||||||
|
export SMTP_RELAY_HOST='$SMTP_RELAY_HOST'
|
||||||
|
export SMTP_RELAY_PORT='$SMTP_RELAY_PORT'
|
||||||
|
export SMTP_RELAY_TLS='$SMTP_RELAY_TLS'
|
||||||
|
export SMTP_RELAY_USER='$SMTP_RELAY_USER'
|
||||||
|
export SMTP_RELAY_PASS='$SMTP_RELAY_PASS'
|
||||||
|
export SMTP_RELAY_FROM='$SMTP_RELAY_FROM'
|
||||||
|
export SMTP_RELAY_ADMIN_FROM='$SMTP_RELAY_ADMIN_FROM'
|
||||||
|
export ADMIN_EMAIL='$ADMIN_EMAIL'
|
||||||
|
export SMTP_RELAY_SERVICE_FROM='$SMTP_RELAY_SERVICE_FROM'
|
||||||
|
export GRAFANA_SMTP_FROM_ADDRESS='$GRAFANA_SMTP_FROM_ADDRESS'
|
||||||
|
export GRAFANA_SMTP_FROM_NAME='$GRAFANA_SMTP_FROM_NAME'
|
||||||
|
export SERVICE_EMAIL='$SERVICE_EMAIL'
|
||||||
|
export GRAFANA_SMTP_PORT='$GRAFANA_SMTP_PORT'
|
||||||
|
export GF_SMTP_ENABLED='$GF_SMTP_ENABLED'
|
||||||
|
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
|
||||||
|
export GATEWAY_HONEYTOKEN='$GATEWAY_HONEYTOKEN'
|
||||||
|
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
||||||
|
EOF
|
||||||
+59
-5
@@ -243,9 +243,10 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
also carries a **one-tap confirm deeplink** (`/app/#/confirm/<token>`, an opaque
|
also carries a **one-tap confirm deeplink** (`/app/#/confirm/<token>`, an opaque
|
||||||
256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the
|
256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the
|
||||||
browser that opens it (magic-link), a link confirms the identity and emits a `notify`
|
browser that opens it (magic-link), a link confirms the identity and emits a `notify`
|
||||||
profile-refresh to the in-app session, and a would-be merge is deferred to the
|
profile-refresh to the in-app session, a change switches the account's email in place,
|
||||||
interactive flow. The confirm page is prefetch-safe — it confirms only on a button
|
and a would-be merge is deferred to the interactive flow. The confirm runs on load; the token rides the URL fragment (never
|
||||||
press, so a mail scanner's GET does not consume the single-use token. An
|
sent to the server), so a plain link prefetch cannot reach it, and the manual
|
||||||
|
six-digit code is the fallback if an aggressive scanner runs the page. An
|
||||||
**email-login** account is created flagged `is_guest` and stays reapable until the
|
**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
|
code is confirmed — so an abandoned, never-confirmed login frees its reserved
|
||||||
address — with confirming clearing the flag. Accounts and identities use
|
address — with confirming clearing the flag. Accounts and identities use
|
||||||
@@ -256,12 +257,52 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
control of the identity before attaching it: **email** through the confirm-code
|
control of the identity before attaching it: **email** through the confirm-code
|
||||||
flow, **Telegram** through the web **Login Widget** (validated by the validator,
|
flow, **Telegram** through the web **Login Widget** (validated by the validator,
|
||||||
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
|
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
|
||||||
passes the trusted `external_id` to the backend, as for `auth.telegram`). The
|
passes the trusted `external_id` to the backend, as for `auth.telegram`), and **VK**
|
||||||
|
through **VK ID web login** (`gateway/internal/vkid`): the browser runs the VK ID raw
|
||||||
|
OAuth 2.1 flow with PKCE — a full-page redirect to VK's hosted login, no `@vkid/sdk` —
|
||||||
|
and the gateway completes the **confidential code exchange** server-side under the VK
|
||||||
|
"Web" app's protected key (a distinct VK app from the Mini App) to obtain the trusted
|
||||||
|
`external_id`. A browser has no signed launch parameters, so unlike the VK Mini App
|
||||||
|
(offline HMAC, §12) this makes an outbound call to `id.vk.com`, and it is web-only — a
|
||||||
|
full-page redirect would strand a native Mini App webview. The
|
||||||
request step **always** sends/accepts the proof (no pre-send "already taken"
|
request step **always** sends/accepts the proof (no pre-send "already taken"
|
||||||
signal, so a probe cannot enumerate registered addresses); a required **merge**
|
signal, so a probe cannot enumerate registered addresses); a required **merge**
|
||||||
is revealed **only after** the proof is verified and is performed behind an
|
is revealed **only after** the proof is verified and is performed behind an
|
||||||
explicit, irreversible confirmation. A free identity is simply attached (and a
|
explicit, irreversible confirmation (for VK, whose authorization code is single-use,
|
||||||
|
the merge re-authorizes for a fresh code). A free identity is simply attached (and a
|
||||||
guest is promoted to durable, clearing `is_guest`).
|
guest is promoted to durable, clearing `is_guest`).
|
||||||
|
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
|
||||||
|
backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
|
||||||
|
never becomes unreachable; the UI mirrors the guard by hiding Unlink when only one
|
||||||
|
method remains. **Email is never unlinked — it is changed.**
|
||||||
|
- **Change email** mails a confirm-code (`purpose=change`) to the new address on the
|
||||||
|
authenticated account and, on confirm (code or one-tap deeplink), **atomically
|
||||||
|
replaces** the account's email identity with the new one, freeing the old address. A
|
||||||
|
new address already confirmed by **another** account is refused **without disclosure**
|
||||||
|
(a neutral "check the address or contact support") and **never merged** — the anti-
|
||||||
|
enumeration check is only reachable by someone who controls the new mailbox.
|
||||||
|
- **Deletion is legal retention, not erasure** (`internal/accountdelete`). The account row
|
||||||
|
survives as a **tombstone** (`accounts.deleted_at`) — its chat/complaint foreign keys
|
||||||
|
have no cascade, so a hard delete is impossible. `AnonymizeAndTombstone` **journals**
|
||||||
|
every live identity into an append-only **`retained_identities`** log (the legal dossier)
|
||||||
|
then removes it, freeing the `(kind, external_id)` for a new account to reuse; it scrubs
|
||||||
|
the live `display_name` → **`[Deleted]`** (an unspoofable sentinel — the editable-name
|
||||||
|
rule forbids brackets) while snapshotting the real name into `deleted_display_name`,
|
||||||
|
anonymises the game-seat snapshots, and drops the account's own friendships / blocks /
|
||||||
|
invitations / friend-codes / drafts / pending-codes. **Chat, feedback and complaints are
|
||||||
|
kept.** The orchestration a layer up resigns the account's active games (so opponents are
|
||||||
|
not stranded), **drops its all-robot games** (no human opponent; children cascade), and
|
||||||
|
revokes its sessions. Every credential detachment — **unlink, email change, delete and a
|
||||||
|
merge collision** — writes a `retained_identities` row (`reason`), so the dossier keeps the
|
||||||
|
full timeline. (A merge that would otherwise leave the survivor with two identities of one
|
||||||
|
kind — e.g. each account held a confirmed email — keeps the primary's and journals the
|
||||||
|
secondary's with `reason=merge` before dropping it.)
|
||||||
|
Step-up is a mailed code (`purpose=delete`, no deeplink — a stray click must not delete;
|
||||||
|
`ConfirmByToken` refuses a delete token) for an email account, else a typed phrase.
|
||||||
|
`last_login_at` / `last_login_ip` are stamped on the cold-load profile fetch (throttled
|
||||||
|
to once an hour). A **two-year TTL reaper** (from the event) purges the whole dossier —
|
||||||
|
the journal, plus a deleted account's feedback thread and dossier PII — while the chat and
|
||||||
|
the tombstone row stay.
|
||||||
- **Merge** retires the account that owns the linked identity into the **current**
|
- **Merge** retires the account that owns the linked identity into the **current**
|
||||||
account, in a single transaction (`internal/accountmerge`): statistics summed
|
account, in a single transaction (`internal/accountmerge`): statistics summed
|
||||||
(counters incl. moves/hints added, max points kept, and the per-variant best moves
|
(counters incl. moves/hints added, max points kept, and the per-variant best moves
|
||||||
@@ -973,6 +1014,19 @@ browser), so an open in-app session reflects it at once.
|
|||||||
both are surfaced on the **Scrabble — Resources** Grafana dashboard, which captures the
|
both are surfaced on the **Scrabble — Resources** Grafana dashboard, which captures the
|
||||||
stress-run resource profile. (`docker_stats` replaced cAdvisor, which on the contour
|
stress-run resource profile. (`docker_stats` replaced cAdvisor, which on the contour
|
||||||
host resolved only the root cgroup — a separate-XFS `/var/lib/docker`.)
|
host resolved only the root cgroup — a separate-XFS `/var/lib/docker`.)
|
||||||
|
- **Alerting.** Grafana emails infra alerts through the shared relay (its own SMTP on the
|
||||||
|
STARTTLS port) to `SERVICE_EMAIL`, from provisioned rules
|
||||||
|
(`deploy/grafana/provisioning/alerting/`): a scrape-target down, the gateway's
|
||||||
|
internal-error rate and p99 latency (`edge_request_*`), host memory/disk/CPU
|
||||||
|
(node_exporter), Postgres connection saturation (postgres_exporter), and **TLS certificate
|
||||||
|
expiry < 20 days** — a Caddy ACME-renewal-failure signal from a **`blackbox_exporter`**
|
||||||
|
probe of the edge caddy (`probe_ssl_earliest_cert_expiry`). Every rule is `noDataState=OK`,
|
||||||
|
so an absent metric never false-alerts — notably the cert probe, which has no TLS target on
|
||||||
|
the HTTP-only contour caddy. Separately, the backend's **admin-alert worker**
|
||||||
|
(`internal/adminalert`, started from `main`) emails the operator (`ADMIN_EMAIL`, from a
|
||||||
|
distinct `SMTP_RELAY_ADMIN_FROM`) when new player feedback or word complaints arrive,
|
||||||
|
**coalescing** a burst into one digest per interval; both recipients may be several
|
||||||
|
comma-separated addresses. Both paths are inert unless configured.
|
||||||
- Per-request server-side timing via gin middleware from day one (the access log
|
- Per-request server-side timing via gin middleware from day one (the access log
|
||||||
carries method, route, status, latency and the active trace id). A
|
carries method, route, status, latency and the active trace id). A
|
||||||
client-measured RTT piggybacked on the next request is a later enhancement.
|
client-measured RTT piggybacked on the next request is a later enhancement.
|
||||||
|
|||||||
+24
-5
@@ -69,7 +69,8 @@ freed. Sends are rate-limited (a short cooldown between codes and a small hourly
|
|||||||
per address), so a mistyped address or an impatient tap cannot flood an inbox. The email
|
per address), so a mistyped address or an impatient tap cannot flood an inbox. The email
|
||||||
also carries a **one-tap link** that confirms the address — or signs the player straight
|
also carries a **one-tap link** that confirms the address — or signs the player straight
|
||||||
in — in a single tap; opening it in another browser reflects in the app at once, and a
|
in — in a single tap; opening it in another browser reflects in the app at once, and a
|
||||||
mail scanner that merely fetches the link never triggers it (it acts only on a button press).
|
mail scanner that merely fetches the link cannot reach it — the token rides the URL
|
||||||
|
fragment, which is never sent to the server.
|
||||||
Telegram runs a **single bot**: every player uses
|
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
|
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
|
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||||||
@@ -95,10 +96,6 @@ reconnect), and pending reads resume on their own — the interface stays usable
|
|||||||
flashing a red banner each time.
|
flashing a red banner each time.
|
||||||
|
|
||||||
### Accounts, linking & merge
|
### Accounts, linking & merge
|
||||||
_Sign-in is currently provider-only, so the in-profile linking UI is temporarily hidden; it
|
|
||||||
returns once the anonymous `/app/` guest (whose upgrade path this is) ships. The flow below
|
|
||||||
describes it for when it does._
|
|
||||||
|
|
||||||
First platform contact auto-provisions a durable account. From the profile a player
|
First platform contact auto-provisions a durable account. From the profile a player
|
||||||
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
|
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
|
||||||
who links their first identity becomes a durable account. The "already taken" status
|
who links their first identity becomes a durable account. The "already taken" status
|
||||||
@@ -110,6 +107,28 @@ when a guest links an identity that already has a durable account, where the dur
|
|||||||
account is kept and the guest's games move into it. A merge is blocked only while the
|
account is kept and the guest's games move into it. A merge is blocked only while the
|
||||||
two accounts share a game still in progress.
|
two accounts share a game still in progress.
|
||||||
|
|
||||||
|
The profile lists the account's **sign-in methods**. On the web a player can add
|
||||||
|
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
|
||||||
|
back); inside a Mini App the host platform is already linked. Linking a provider that
|
||||||
|
already belongs to another account offers the same irreversible **merge** as email
|
||||||
|
linking. A linked provider can be **unlinked** — except the last
|
||||||
|
remaining sign-in method, which is refused so the account stays reachable. **Email is
|
||||||
|
never unlinked; it is changed**: the player enters a new address, confirms a code
|
||||||
|
mailed to it, and the account switches to it atomically, freeing the old address. A new
|
||||||
|
address that already belongs to another account is refused with a neutral "check the
|
||||||
|
address or contact support" — the switch never merges and never reveals the other
|
||||||
|
account.
|
||||||
|
|
||||||
|
A player can **delete their account**. This is a legal-retention removal, not an erasure:
|
||||||
|
the account is deactivated and its live surfaces anonymised (opponents see "[Deleted]"), its
|
||||||
|
sign-in methods are freed for reuse, and its sessions are revoked — but a dossier (the
|
||||||
|
credentials that were linked, the last login, and the player's messages) is retained for
|
||||||
|
the operator and purged after two years. Confirming deletion needs a mailed code for an
|
||||||
|
account with an email, or a typed phrase otherwise. Active games are forfeited so opponents
|
||||||
|
are not stranded; solo games against the AI are removed, while games with a human opponent
|
||||||
|
are kept under the anonymised name and the player's chat stays. Reopening the app after
|
||||||
|
deletion simply creates a fresh account.
|
||||||
|
|
||||||
### Lobby & matchmaking
|
### Lobby & matchmaking
|
||||||
On a cold open the lobby greets the player with a brief **loading splash** — Scrabble tiles
|
On a cold open the lobby greets the player with a brief **loading splash** — Scrabble tiles
|
||||||
spelling **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** as a small crossword — that clears the moment the
|
spelling **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** as a small crossword — that clears the moment the
|
||||||
|
|||||||
+23
-6
@@ -73,8 +73,8 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
|||||||
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
|
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
|
||||||
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
||||||
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
||||||
приложении, а почтовый сканер, который просто загружает ссылку, её не срабатывает (действие
|
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
|
||||||
только по нажатию кнопки).
|
он едет в URL-фрагменте, который не уходит на сервер.
|
||||||
Telegram держит **единого бота**: все игроки пользуются одним
|
Telegram держит **единого бота**: все игроки пользуются одним
|
||||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||||
@@ -100,10 +100,6 @@ Telegram держит **единого бота**: все игроки поль
|
|||||||
рабочим вместо красного баннера каждый раз.
|
рабочим вместо красного баннера каждый раз.
|
||||||
|
|
||||||
### Аккаунты, привязка и слияние
|
### Аккаунты, привязка и слияние
|
||||||
_Вход сейчас только через провайдера, поэтому UI привязки в профиле временно скрыт; он
|
|
||||||
вернётся, когда появится анонимный `/app/`-гость (для апгрейда которого он и нужен). Описание
|
|
||||||
ниже — на этот случай._
|
|
||||||
|
|
||||||
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
||||||
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
||||||
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
||||||
@@ -115,6 +111,27 @@ _Вход сейчас только через провайдера, поэто
|
|||||||
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
|
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
|
||||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||||
|
|
||||||
|
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
||||||
|
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
||||||
|
обратно); внутри Mini App платформа-хозяин уже привязана. Привязка провайдера, уже
|
||||||
|
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
|
||||||
|
email. Привязанного провайдера можно **отвязать** — кроме последнего
|
||||||
|
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
|
||||||
|
**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
|
||||||
|
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
|
||||||
|
старый. Новый адрес, уже принадлежащий другому аккаунту, отклоняется нейтральным
|
||||||
|
«проверьте правильность e-mail или обратитесь в поддержку» — смена никогда не сливает
|
||||||
|
аккаунты и не раскрывает чужой.
|
||||||
|
|
||||||
|
Игрок может **удалить аккаунт**. Это удаление с юридическим удержанием, а не стирание:
|
||||||
|
аккаунт деактивируется, живые поверхности обезличиваются (соперники видят «[Deleted]»),
|
||||||
|
способы входа освобождаются под повторную регистрацию, сессии отзываются — но досье
|
||||||
|
(какие креды были привязаны, последний вход, сообщения игрока) сохраняется для оператора и
|
||||||
|
чистится через два года. Подтверждение удаления — код на почту (если у аккаунта есть
|
||||||
|
e-mail) либо ввод фразы. Активные игры форфейтятся, чтобы не бросать соперников; одиночные
|
||||||
|
игры против ИИ удаляются, а игры с людьми сохраняются под обезличенным именем, чат игрока
|
||||||
|
остаётся. Если снова открыть приложение после удаления — создаётся новый аккаунт.
|
||||||
|
|
||||||
### Лобби и подбор
|
### Лобби и подбор
|
||||||
При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble
|
При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble
|
||||||
складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как
|
складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ ARG VITE_TELEGRAM_BOT_ID=
|
|||||||
ARG VITE_TELEGRAM_LINK=
|
ARG VITE_TELEGRAM_LINK=
|
||||||
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME=
|
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME=
|
||||||
ARG VITE_VK_APP_LINK=
|
ARG VITE_VK_APP_LINK=
|
||||||
|
ARG VITE_VK_APP_ID=
|
||||||
|
ARG VITE_VK_ID_REDIRECT_URL=
|
||||||
ARG VITE_GATEWAY_URL=
|
ARG VITE_GATEWAY_URL=
|
||||||
ARG VITE_APP_VERSION=
|
ARG VITE_APP_VERSION=
|
||||||
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||||
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
|
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
|
||||||
VITE_VK_APP_LINK=$VITE_VK_APP_LINK \
|
VITE_VK_APP_LINK=$VITE_VK_APP_LINK \
|
||||||
|
VITE_VK_APP_ID=$VITE_VK_APP_ID \
|
||||||
|
VITE_VK_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_URL \
|
||||||
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
|
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
|
||||||
VITE_APP_VERSION=$VITE_APP_VERSION
|
VITE_APP_VERSION=$VITE_APP_VERSION
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -61,6 +61,12 @@ round-trip, then forwards the trusted `vk_user_id` (and the client-read display
|
|||||||
from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled
|
from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled
|
||||||
(`auth.vk` is unregistered).
|
(`auth.vk` is unregistered).
|
||||||
|
|
||||||
|
`link.vk.confirm`/`merge` link a VK identity from a **browser** (not a Mini App) via **VK ID web
|
||||||
|
login** (`internal/vkid`): the SPA runs the VK ID raw OAuth 2.1 flow (PKCE, no `@vkid/sdk`) and
|
||||||
|
the gateway completes the **confidential code exchange** at `id.vk.com` under a SEPARATE VK "Web"
|
||||||
|
app's protected key — the only op here that calls VK (the Mini App path above is offline). All
|
||||||
|
three `GATEWAY_VK_ID_*` unset leaves the ops unregistered.
|
||||||
|
|
||||||
The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`,
|
The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`,
|
||||||
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
||||||
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
|
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
|
||||||
@@ -72,9 +78,10 @@ refetch). The social/account/history ops —
|
|||||||
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
|
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
|
||||||
`stats.get`, `game.gcg`, and the `notify` live event — go through the identical
|
`stats.get`, `game.gcg`, and the `notify` live event — go through the identical
|
||||||
transcode pattern (`transcode_social.go`). Account linking & merge
|
transcode pattern (`transcode_social.go`). Account linking & merge
|
||||||
— `link.email.request/confirm/merge` and `link.telegram.confirm/merge`
|
— `link.email.request/confirm/merge`, `link.telegram.confirm/merge` and
|
||||||
(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the
|
`link.vk.confirm/merge` (`transcode_link.go`); the telegram ops validate the **Login
|
||||||
validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
Widget** payload via the validator (`ValidateLoginWidget`), the vk ops complete the VK ID
|
||||||
|
web code exchange via `internal/vkid`, and both forward the trusted `external_id`. These
|
||||||
**superseded** the former `email.bind.*` ops, which were removed.
|
**superseded** the former `email.bind.*` ops, which were removed.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -89,6 +96,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
|||||||
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
|
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
|
||||||
| `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
|
| `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
|
||||||
| `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) |
|
| `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) |
|
||||||
|
| `GATEWAY_VK_ID_APP_ID` / `GATEWAY_VK_ID_CLIENT_SECRET` / `GATEWAY_VK_ID_REDIRECT_URL` | unset | VK ID "Web" app credentials for VK web-login linking (`link.vk.*`): the server-side confidential OAuth 2.1 code exchange. A separate VK app from `GATEWAY_VK_APP_SECRET`; all three required to enable the ops |
|
||||||
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
|
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
|
||||||
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
|
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
|
||||||
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
|
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
"scrabble/gateway/internal/ratelimit"
|
"scrabble/gateway/internal/ratelimit"
|
||||||
"scrabble/gateway/internal/session"
|
"scrabble/gateway/internal/session"
|
||||||
"scrabble/gateway/internal/transcode"
|
"scrabble/gateway/internal/transcode"
|
||||||
|
"scrabble/gateway/internal/vkid"
|
||||||
"scrabble/pkg/mtls"
|
"scrabble/pkg/mtls"
|
||||||
botlinkv1 "scrabble/pkg/proto/botlink/v1"
|
botlinkv1 "scrabble/pkg/proto/botlink/v1"
|
||||||
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
||||||
@@ -190,7 +191,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
|
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
|
||||||
}
|
}
|
||||||
|
|
||||||
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret))
|
// VK ID web login (browser VK-identity linking) is optional: build the confidential
|
||||||
|
// code-exchanger only when fully configured, else leave the interface nil so the
|
||||||
|
// link.vk.* ops stay unregistered.
|
||||||
|
var vkidExchanger transcode.VKIDExchanger
|
||||||
|
if cfg.VKID.Enabled() {
|
||||||
|
vkidExchanger = vkid.New(cfg.VKID.AppID, cfg.VKID.ClientSecret, cfg.VKID.RedirectURI)
|
||||||
|
logger.Info("vk id web login enabled")
|
||||||
|
}
|
||||||
|
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger))
|
||||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ type ProfileResp struct {
|
|||||||
// Banner is the advertising-banner block, present only for a viewer eligible to
|
// Banner is the advertising-banner block, present only for a viewer eligible to
|
||||||
// see the banner. The gateway forwards it verbatim into the Profile payload.
|
// see the banner. The gateway forwards it verbatim into the Profile payload.
|
||||||
Banner *BannerResp `json:"banner,omitempty"`
|
Banner *BannerResp `json:"banner,omitempty"`
|
||||||
|
// Email is the confirmed email ("" when none); TelegramLinked/VkLinked report an
|
||||||
|
// attached platform identity — they drive the profile's link/unlink/change controls.
|
||||||
|
Email string `json:"email"`
|
||||||
|
TelegramLinked bool `json:"telegram_linked"`
|
||||||
|
VkLinked bool `json:"vk_linked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BannerResp is the advertising-banner block of an eligible viewer's profile: the
|
// BannerResp is the advertising-banner block of an eligible viewer's profile: the
|
||||||
|
|||||||
@@ -335,6 +335,69 @@ func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID strin
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinkVK attaches a gateway-validated VK identity to the caller or reports a required
|
||||||
|
// merge. externalID is the trusted vk user id resolved from the VK ID code exchange.
|
||||||
|
func (c *Client) LinkVK(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
|
||||||
|
var out LinkResultResp
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/vk", userID, "",
|
||||||
|
map[string]string{"external_id": externalID}, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkVKMerge merges the account owning a gateway-validated VK identity into the
|
||||||
|
// caller's.
|
||||||
|
func (c *Client) LinkVKMerge(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
|
||||||
|
var out LinkResultResp
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/vk/merge", userID, "",
|
||||||
|
map[string]string{"external_id": externalID}, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeEmailRequest asks the backend to mail a confirm-code to a new address for an
|
||||||
|
// authenticated email change.
|
||||||
|
func (c *Client) ChangeEmailRequest(ctx context.Context, userID, email string) error {
|
||||||
|
return c.do(ctx, http.MethodPost, "/api/v1/user/link/email/change/request", userID, "",
|
||||||
|
map[string]string{"email": email}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeEmailConfirm verifies the code and atomically switches the account's email,
|
||||||
|
// returning the refreshed profile in the result.
|
||||||
|
func (c *Client) ChangeEmailConfirm(ctx context.Context, userID, email, code string) (LinkResultResp, error) {
|
||||||
|
var out LinkResultResp
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/change/confirm", userID, "",
|
||||||
|
map[string]string{"email": email, "code": code}, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRequestResp reports which account-deletion step-up the account uses.
|
||||||
|
type DeleteRequestResp struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRequest starts account deletion: the backend mails a delete code (email accounts)
|
||||||
|
// or reports the typed-phrase path.
|
||||||
|
func (c *Client) DeleteRequest(ctx context.Context, userID string) (DeleteRequestResp, error) {
|
||||||
|
var out DeleteRequestResp
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/delete/request", userID, "", nil, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteConfirm verifies the step-up (a mailed code or the typed phrase) and deletes the
|
||||||
|
// account.
|
||||||
|
func (c *Client) DeleteConfirm(ctx context.Context, userID, code, phrase string) error {
|
||||||
|
return c.do(ctx, http.MethodPost, "/api/v1/user/delete/confirm", userID, "",
|
||||||
|
map[string]string{"code": code, "phrase": phrase}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkUnlink detaches a platform identity (kind = "telegram" | "vk") from the caller
|
||||||
|
// and returns the refreshed profile in the result.
|
||||||
|
func (c *Client) LinkUnlink(ctx context.Context, userID, kind string) (LinkResultResp, error) {
|
||||||
|
var out LinkResultResp
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/unlink", userID, "",
|
||||||
|
map[string]string{"kind": kind}, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
// Stats returns the caller's lifetime statistics.
|
// Stats returns the caller's lifetime statistics.
|
||||||
func (c *Client) Stats(ctx context.Context, userID string) (StatsResp, error) {
|
func (c *Client) Stats(ctx context.Context, userID string) (StatsResp, error) {
|
||||||
var out StatsResp
|
var out StatsResp
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ type Config struct {
|
|||||||
// VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API
|
// VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API
|
||||||
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
|
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
|
||||||
VKAppSecret string
|
VKAppSecret string
|
||||||
|
// VKID configures the VK ID web login used to link a VK identity from a browser
|
||||||
|
// (the confidential OAuth 2.1 code exchange against id.vk.com). It belongs to a
|
||||||
|
// separate VK "Web" app from VKAppSecret's Mini App, so its credentials are distinct.
|
||||||
|
// Any field empty disables the VK web-link ops (link.vk.*).
|
||||||
|
VKID VKIDConfig
|
||||||
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
|
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
|
||||||
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
|
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
|
||||||
BotLink BotLinkConfig
|
BotLink BotLinkConfig
|
||||||
@@ -161,6 +166,22 @@ func DefaultAbuse() AbuseConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VKIDConfig holds the VK ID web-login credentials for the confidential
|
||||||
|
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
|
||||||
|
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
|
||||||
|
// with the app and the one the frontend uses. All three are required to enable the flow.
|
||||||
|
type VKIDConfig struct {
|
||||||
|
AppID string
|
||||||
|
ClientSecret string
|
||||||
|
RedirectURI string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether VK ID web login is fully configured. When false the gateway
|
||||||
|
// leaves the VK web-link ops (link.vk.*) unregistered.
|
||||||
|
func (c VKIDConfig) Enabled() bool {
|
||||||
|
return c.AppID != "" && c.ClientSecret != "" && c.RedirectURI != ""
|
||||||
|
}
|
||||||
|
|
||||||
// Load reads the configuration from the environment, applies defaults, and
|
// Load reads the configuration from the environment, applies defaults, and
|
||||||
// validates the result.
|
// validates the result.
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -174,6 +195,11 @@ func Load() (Config, error) {
|
|||||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||||
|
VKID: VKIDConfig{
|
||||||
|
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
||||||
|
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
|
||||||
|
RedirectURI: os.Getenv("GATEWAY_VK_ID_REDIRECT_URL"),
|
||||||
|
},
|
||||||
SessionCacheMax: defaultSessionCacheMax,
|
SessionCacheMax: defaultSessionCacheMax,
|
||||||
RateLimit: DefaultRateLimit(),
|
RateLimit: DefaultRateLimit(),
|
||||||
Abuse: DefaultAbuse(),
|
Abuse: DefaultAbuse(),
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ func encodeAck(ok bool) []byte {
|
|||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeDeleteRequestResult builds an AccountDeleteRequestResult payload reporting which
|
||||||
|
// deletion step-up the account uses ("email" | "phrase").
|
||||||
|
func encodeDeleteRequestResult(method string) []byte {
|
||||||
|
b := flatbuffers.NewBuilder(32)
|
||||||
|
m := b.CreateString(method)
|
||||||
|
fb.AccountDeleteRequestResultStart(b)
|
||||||
|
fb.AccountDeleteRequestResultAddMethod(b, m)
|
||||||
|
b.Finish(fb.AccountDeleteRequestResultEnd(b))
|
||||||
|
return b.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
// encodeConfirmLinkResult builds an EmailConfirmLinkResult payload, embedding the
|
// encodeConfirmLinkResult builds an EmailConfirmLinkResult payload, embedding the
|
||||||
// minted Session for a login. All strings and the nested Session table are built
|
// minted Session for a login. All strings and the nested Session table are built
|
||||||
// before the result table is opened.
|
// before the result table is opened.
|
||||||
@@ -76,6 +87,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
tz := b.CreateString(p.TimeZone)
|
tz := b.CreateString(p.TimeZone)
|
||||||
awayStart := b.CreateString(p.AwayStart)
|
awayStart := b.CreateString(p.AwayStart)
|
||||||
awayEnd := b.CreateString(p.AwayEnd)
|
awayEnd := b.CreateString(p.AwayEnd)
|
||||||
|
email := b.CreateString(p.Email)
|
||||||
// Build the banner table (and its children) before opening Profile: FlatBuffers
|
// Build the banner table (and its children) before opening Profile: FlatBuffers
|
||||||
// forbids a nested table while another is under construction.
|
// forbids a nested table while another is under construction.
|
||||||
var banner flatbuffers.UOffsetT
|
var banner flatbuffers.UOffsetT
|
||||||
@@ -96,6 +108,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
fb.ProfileAddAwayEnd(b, awayEnd)
|
fb.ProfileAddAwayEnd(b, awayEnd)
|
||||||
fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly)
|
fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly)
|
||||||
fb.ProfileAddVariantPreferences(b, prefs)
|
fb.ProfileAddVariantPreferences(b, prefs)
|
||||||
|
fb.ProfileAddEmail(b, email)
|
||||||
|
fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
|
||||||
|
fb.ProfileAddVkLinked(b, p.VkLinked)
|
||||||
if p.Banner != nil {
|
if p.Banner != nil {
|
||||||
fb.ProfileAddBanner(b, banner)
|
fb.ProfileAddBanner(b, banner)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
"scrabble/gateway/internal/connector"
|
"scrabble/gateway/internal/connector"
|
||||||
"scrabble/gateway/internal/vkauth"
|
"scrabble/gateway/internal/vkauth"
|
||||||
|
"scrabble/gateway/internal/vkid"
|
||||||
fb "scrabble/pkg/fbs/scrabblefb"
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,6 +153,15 @@ func WithVKAuth(secret string) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithVKLink registers the VK web-link ops (link.vk.confirm/merge), which complete a
|
||||||
|
// browser VK ID authorization via the server-side code exchange. A nil exchanger leaves
|
||||||
|
// them unregistered, so the ops are unknown wherever VK ID web login is not configured.
|
||||||
|
func WithVKLink(ex VKIDExchanger) Option {
|
||||||
|
return func(r *Registry, backend *backendclient.Client) {
|
||||||
|
registerVKLinkOps(r, backend, ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lookup returns the operation for messageType, and whether it is registered.
|
// Lookup returns the operation for messageType, and whether it is registered.
|
||||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||||
op, ok := r.ops[messageType]
|
op, ok := r.ops[messageType]
|
||||||
@@ -172,6 +182,9 @@ func DomainCode(err error) (string, bool) {
|
|||||||
if errors.Is(err, vkauth.ErrInvalid) {
|
if errors.Is(err, vkauth.ErrInvalid) {
|
||||||
return "invalid_vk_params", true
|
return "invalid_vk_params", true
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, vkid.ErrInvalid) {
|
||||||
|
return "invalid_vk_id", true
|
||||||
|
}
|
||||||
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
||||||
return "invalid_login_widget", true
|
return "invalid_login_widget", true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
|
"scrabble/gateway/internal/vkid"
|
||||||
fb "scrabble/pkg/fbs/scrabblefb"
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +19,13 @@ const (
|
|||||||
MsgLinkEmailMerge = "link.email.merge"
|
MsgLinkEmailMerge = "link.email.merge"
|
||||||
MsgLinkTelegram = "link.telegram.confirm"
|
MsgLinkTelegram = "link.telegram.confirm"
|
||||||
MsgLinkTelegramMerge = "link.telegram.merge"
|
MsgLinkTelegramMerge = "link.telegram.merge"
|
||||||
|
MsgLinkVK = "link.vk.confirm"
|
||||||
|
MsgLinkVKMerge = "link.vk.merge"
|
||||||
|
MsgLinkUnlink = "link.unlink"
|
||||||
|
MsgEmailChangeRequest = "link.email.change.request"
|
||||||
|
MsgEmailChangeConfirm = "link.email.change.confirm"
|
||||||
|
MsgAccountDeleteReq = "account.delete.request"
|
||||||
|
MsgAccountDeleteConf = "account.delete.confirm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// registerLinkOps adds the linking & merge operations. The telegram ops need the
|
// registerLinkOps adds the linking & merge operations. The telegram ops need the
|
||||||
@@ -26,6 +34,11 @@ func registerLinkOps(r *Registry, backend *backendclient.Client, tg TelegramVali
|
|||||||
r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true}
|
r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true}
|
||||||
r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend), Auth: true, Email: true}
|
r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend), Auth: true, Email: true}
|
||||||
r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend), Auth: true, Email: true}
|
r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend), Auth: true, Email: true}
|
||||||
|
r.ops[MsgLinkUnlink] = Op{Handler: linkUnlinkHandler(backend), Auth: true}
|
||||||
|
r.ops[MsgEmailChangeRequest] = Op{Handler: changeEmailRequestHandler(backend), Auth: true, Email: true}
|
||||||
|
r.ops[MsgEmailChangeConfirm] = Op{Handler: changeEmailConfirmHandler(backend), Auth: true, Email: true}
|
||||||
|
r.ops[MsgAccountDeleteReq] = Op{Handler: deleteRequestHandler(backend), Auth: true, Email: true}
|
||||||
|
r.ops[MsgAccountDeleteConf] = Op{Handler: deleteConfirmHandler(backend), Auth: true}
|
||||||
if tg != nil {
|
if tg != nil {
|
||||||
r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false), Auth: true}
|
r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false), Auth: true}
|
||||||
r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true), Auth: true}
|
r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true), Auth: true}
|
||||||
@@ -64,6 +77,65 @@ func linkEmailMergeHandler(backend *backendclient.Client) Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// changeEmailRequestHandler mails a confirm-code to a new address for an email change.
|
||||||
|
func changeEmailRequestHandler(backend *backendclient.Client) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
in := fb.GetRootAsLinkEmailRequest(req.Payload, 0)
|
||||||
|
if err := backend.ChangeEmailRequest(ctx, req.UserID, string(in.Email())); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeAck(true), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// changeEmailConfirmHandler verifies the code and switches the account's email, returning
|
||||||
|
// the refreshed link result (status "changed" + the updated profile).
|
||||||
|
func changeEmailConfirmHandler(backend *backendclient.Client) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0)
|
||||||
|
res, err := backend.ChangeEmailConfirm(ctx, req.UserID, string(in.Email()), string(in.Code()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeLinkResult(res), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteRequestHandler starts account deletion, returning which step-up the account uses.
|
||||||
|
func deleteRequestHandler(backend *backendclient.Client) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
res, err := backend.DeleteRequest(ctx, req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeDeleteRequestResult(res.Method), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteConfirmHandler verifies the step-up proof and deletes the account.
|
||||||
|
func deleteConfirmHandler(backend *backendclient.Client) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
in := fb.GetRootAsAccountDeleteConfirm(req.Payload, 0)
|
||||||
|
if err := backend.DeleteConfirm(ctx, req.UserID, string(in.Code()), string(in.Phrase())); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeAck(true), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkUnlinkHandler detaches a platform identity (telegram|vk) from the caller and
|
||||||
|
// returns the refreshed link result (status "unlinked" + the updated profile).
|
||||||
|
func linkUnlinkHandler(backend *backendclient.Client) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
in := fb.GetRootAsLinkUnlinkRequest(req.Payload, 0)
|
||||||
|
res, err := backend.LinkUnlink(ctx, req.UserID, string(in.Kind()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeLinkResult(res), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// linkTelegramHandler validates Login Widget data via the connector and then calls
|
// linkTelegramHandler validates Login Widget data via the connector and then calls
|
||||||
// the backend's link or merge endpoint with the trusted Telegram external id.
|
// the backend's link or merge endpoint with the trusted Telegram external id.
|
||||||
func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, merge bool) Handler {
|
func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, merge bool) Handler {
|
||||||
@@ -85,3 +157,44 @@ func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, me
|
|||||||
return encodeLinkResult(res), nil
|
return encodeLinkResult(res), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VKIDExchanger completes a VK ID web authorization-code exchange, returning the
|
||||||
|
// launching user's trusted VK identity. It is satisfied by *vkid.Exchanger and lets the
|
||||||
|
// VK web-link ops resolve a browser VK login, which — unlike the Mini App path — has no
|
||||||
|
// offline launch signature to verify.
|
||||||
|
type VKIDExchanger interface {
|
||||||
|
Exchange(ctx context.Context, code, deviceID, codeVerifier string) (vkid.Identity, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerVKLinkOps adds the VK web-link ops when a VK ID exchanger is configured; a nil
|
||||||
|
// exchanger leaves them unregistered (VK ID web login not configured).
|
||||||
|
func registerVKLinkOps(r *Registry, backend *backendclient.Client, ex VKIDExchanger) {
|
||||||
|
if ex == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.ops[MsgLinkVK] = Op{Handler: linkVKHandler(backend, ex, false), Auth: true}
|
||||||
|
r.ops[MsgLinkVKMerge] = Op{Handler: linkVKHandler(backend, ex, true), Auth: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkVKHandler completes the VK ID code exchange (server-side, under the app's
|
||||||
|
// protected key) and then calls the backend's link or merge endpoint with the trusted
|
||||||
|
// VK external id.
|
||||||
|
func linkVKHandler(backend *backendclient.Client, ex VKIDExchanger, merge bool) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
in := fb.GetRootAsLinkVKRequest(req.Payload, 0)
|
||||||
|
user, err := ex.Exchange(ctx, string(in.Code()), string(in.DeviceId()), string(in.CodeVerifier()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var res backendclient.LinkResultResp
|
||||||
|
if merge {
|
||||||
|
res, err = backend.LinkVKMerge(ctx, req.UserID, user.ExternalID)
|
||||||
|
} else {
|
||||||
|
res, err = backend.LinkVK(ctx, req.UserID, user.ExternalID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeLinkResult(res), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package transcode_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/transcode"
|
||||||
|
"scrabble/gateway/internal/vkid"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func linkVKPayload(code, deviceID, verifier string) []byte {
|
||||||
|
b := flatbuffers.NewBuilder(64)
|
||||||
|
c := b.CreateString(code)
|
||||||
|
d := b.CreateString(deviceID)
|
||||||
|
v := b.CreateString(verifier)
|
||||||
|
fb.LinkVKRequestStart(b)
|
||||||
|
fb.LinkVKRequestAddCode(b, c)
|
||||||
|
fb.LinkVKRequestAddDeviceId(b, d)
|
||||||
|
fb.LinkVKRequestAddCodeVerifier(b, v)
|
||||||
|
b.Finish(fb.LinkVKRequestEnd(b))
|
||||||
|
return b.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeVKIDExchanger records the exchange inputs and returns a canned identity.
|
||||||
|
type fakeVKIDExchanger struct {
|
||||||
|
id vkid.Identity
|
||||||
|
err error
|
||||||
|
gotCode, gotDevice, gotVerifier string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeVKIDExchanger) Exchange(_ context.Context, code, deviceID, codeVerifier string) (vkid.Identity, error) {
|
||||||
|
f.gotCode, f.gotDevice, f.gotVerifier = code, deviceID, codeVerifier
|
||||||
|
return f.id, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkVKExchangesAndForwards(t *testing.T) {
|
||||||
|
var gotExternalID string
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/user/link/vk" {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ExternalID string `json:"external_id"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
gotExternalID = body.ExternalID
|
||||||
|
_, _ = w.Write([]byte(`{"status":"linked"}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ex := &fakeVKIDExchanger{id: vkid.Identity{ExternalID: "777"}}
|
||||||
|
reg := transcode.NewRegistry(backend, nil, transcode.WithVKLink(ex))
|
||||||
|
op, ok := reg.Lookup(transcode.MsgLinkVK)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("link.vk.confirm not registered")
|
||||||
|
}
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: linkVKPayload("the-code", "dev-1", "verifier-1")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
// The PKCE inputs from the wire must reach the exchanger verbatim...
|
||||||
|
if ex.gotCode != "the-code" || ex.gotDevice != "dev-1" || ex.gotVerifier != "verifier-1" {
|
||||||
|
t.Errorf("exchange got %q/%q/%q", ex.gotCode, ex.gotDevice, ex.gotVerifier)
|
||||||
|
}
|
||||||
|
// ...and the resolved vk id must be the one forwarded to the backend.
|
||||||
|
if gotExternalID != "777" {
|
||||||
|
t.Errorf("backend external_id = %q, want 777", gotExternalID)
|
||||||
|
}
|
||||||
|
if string(fb.GetRootAsLinkResult(payload, 0).Status()) != "linked" {
|
||||||
|
t.Error("expected a linked result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkVKUnregisteredWithoutExchanger(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
if _, ok := reg.Lookup(transcode.MsgLinkVK); ok {
|
||||||
|
t.Error("link.vk.confirm must be unregistered when no VK ID exchanger is configured")
|
||||||
|
}
|
||||||
|
if _, ok := reg.Lookup(transcode.MsgLinkVKMerge); ok {
|
||||||
|
t.Error("link.vk.merge must be unregistered when no VK ID exchanger is configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Package vkid completes VK ID web authorization on the server. A browser linking a
|
||||||
|
// VK identity has no signed Mini App launch parameters (that offline HMAC path is
|
||||||
|
// vkauth); instead the frontend runs the VK ID raw OAuth 2.1 flow (PKCE) and hands the
|
||||||
|
// gateway the authorization code, which the gateway exchanges — confidentially, under
|
||||||
|
// the app's protected key — for the launching user's trusted VK id. Unlike the Mini App
|
||||||
|
// path this makes an outbound call to VK (there is no offline verification for the web
|
||||||
|
// flow). See docs/ARCHITECTURE.md §12.
|
||||||
|
package vkid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// tokenEndpoint is VK ID's OAuth 2.1 token endpoint. The frontend obtains the
|
||||||
|
// authorization code against the same host, so the confidential exchange targets it
|
||||||
|
// too. It is a fixed constant (not user input), so the outbound request carries no
|
||||||
|
// SSRF risk.
|
||||||
|
tokenEndpoint = "https://id.vk.com/oauth2/auth"
|
||||||
|
// exchangeTimeout bounds one code-for-token exchange. Linking is interactive, so an
|
||||||
|
// unreachable VK must fail fast rather than hold the request open.
|
||||||
|
exchangeTimeout = 10 * time.Second
|
||||||
|
// maxResponseBytes caps the token-response read to bound memory on an oversized body.
|
||||||
|
maxResponseBytes = 1 << 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalid reports an authorization fault: the exchange was rejected or yielded no vk
|
||||||
|
// user id (a bad or expired code, a mismatched verifier or redirect). It is distinct
|
||||||
|
// from a transport failure reaching VK, which surfaces as a wrapped error.
|
||||||
|
var ErrInvalid = errors.New("vkid: vk id authorization exchange failed")
|
||||||
|
|
||||||
|
// Identity is the user resolved from a completed VK ID exchange. ExternalID is the vk
|
||||||
|
// user id, used as the identities external_id.
|
||||||
|
type Identity struct {
|
||||||
|
ExternalID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// numericID accepts a VK user id that the token endpoint returns inconsistently as a
|
||||||
|
// JSON string or a JSON number, normalising both to their decimal string form (empty
|
||||||
|
// for a null or absent field).
|
||||||
|
type numericID string
|
||||||
|
|
||||||
|
func (n *numericID) UnmarshalJSON(b []byte) error {
|
||||||
|
s := strings.Trim(string(b), `"`)
|
||||||
|
if s == "null" {
|
||||||
|
s = ""
|
||||||
|
}
|
||||||
|
*n = numericID(s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchanger completes VK ID confidential authorization-code exchanges for one app.
|
||||||
|
type Exchanger struct {
|
||||||
|
appID string
|
||||||
|
clientSecret string
|
||||||
|
redirectURI string
|
||||||
|
endpoint string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs an Exchanger for the app credentials. redirectURI must equal the
|
||||||
|
// trusted redirect URL registered with the app and the one the frontend used, or VK
|
||||||
|
// rejects the exchange.
|
||||||
|
func New(appID, clientSecret, redirectURI string) *Exchanger {
|
||||||
|
return &Exchanger{
|
||||||
|
appID: appID,
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
redirectURI: redirectURI,
|
||||||
|
endpoint: tokenEndpoint,
|
||||||
|
httpClient: &http.Client{Timeout: exchangeTimeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange completes the authorization-code grant and returns the trusted vk user id.
|
||||||
|
// code, deviceID and codeVerifier are the PKCE inputs the frontend obtained from the VK
|
||||||
|
// ID authorization redirect; the exchange authenticates with the app's protected key.
|
||||||
|
func (e *Exchanger) Exchange(ctx context.Context, code, deviceID, codeVerifier string) (Identity, error) {
|
||||||
|
if code == "" || deviceID == "" || codeVerifier == "" {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"code": {code},
|
||||||
|
"code_verifier": {codeVerifier},
|
||||||
|
"device_id": {deviceID},
|
||||||
|
"client_id": {e.appID},
|
||||||
|
"client_secret": {e.clientSecret},
|
||||||
|
"redirect_uri": {e.redirectURI},
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.endpoint, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return Identity{}, fmt.Errorf("vkid: build exchange request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := e.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Identity{}, fmt.Errorf("vkid: exchange request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||||
|
if err != nil {
|
||||||
|
return Identity{}, fmt.Errorf("vkid: read exchange response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// VK answers 400 with {error, error_description} on a bad/expired code or a
|
||||||
|
// verifier/redirect mismatch — an authorization fault, not a transport error.
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
UserID numericID `json:"user_id"`
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
if out.Error != "" {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
uid := string(out.UserID)
|
||||||
|
if uid == "" || uid == "0" {
|
||||||
|
uid = subjectFromIDToken(out.IDToken)
|
||||||
|
}
|
||||||
|
if uid == "" {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
return Identity{ExternalID: uid}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// subjectFromIDToken extracts the OIDC subject (the vk user id) from the payload of a
|
||||||
|
// VK ID id_token. The token arrives inside a direct TLS response from VK, so its
|
||||||
|
// signature is not re-verified here; the claim is only a fallback for a response that
|
||||||
|
// omits an explicit user_id.
|
||||||
|
func subjectFromIDToken(idToken string) string {
|
||||||
|
parts := strings.Split(idToken, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var claims struct {
|
||||||
|
Sub numericID `json:"sub"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s := string(claims.Sub); s != "0" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package vkid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testExchanger builds an Exchanger pointed at a test server.
|
||||||
|
func testExchanger(endpoint string) *Exchanger {
|
||||||
|
return &Exchanger{
|
||||||
|
appID: "app-1",
|
||||||
|
clientSecret: "secret-1",
|
||||||
|
redirectURI: "https://example.test/app/",
|
||||||
|
endpoint: endpoint,
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeSuccessSendsConfidentialPKCE(t *testing.T) {
|
||||||
|
var gotForm url.Values
|
||||||
|
var gotContentType string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotContentType = r.Header.Get("Content-Type")
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
gotForm, _ = url.ParseQuery(string(body))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"user_id":"12345","access_token":"a","id_token":"h.e.s"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
id, err := testExchanger(srv.URL).Exchange(context.Background(), "the-code", "dev-9", "verifier-xyz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exchange: %v", err)
|
||||||
|
}
|
||||||
|
if id.ExternalID != "12345" {
|
||||||
|
t.Fatalf("ExternalID = %q, want 12345", id.ExternalID)
|
||||||
|
}
|
||||||
|
if gotContentType != "application/x-www-form-urlencoded" {
|
||||||
|
t.Errorf("Content-Type = %q", gotContentType)
|
||||||
|
}
|
||||||
|
// The confidential exchange must carry the PKCE inputs and the app credentials.
|
||||||
|
want := map[string]string{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": "the-code",
|
||||||
|
"code_verifier": "verifier-xyz",
|
||||||
|
"device_id": "dev-9",
|
||||||
|
"client_id": "app-1",
|
||||||
|
"client_secret": "secret-1",
|
||||||
|
"redirect_uri": "https://example.test/app/",
|
||||||
|
}
|
||||||
|
for k, v := range want {
|
||||||
|
if gotForm.Get(k) != v {
|
||||||
|
t.Errorf("form[%s] = %q, want %q", k, gotForm.Get(k), v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeAcceptsNumericUserID(t *testing.T) {
|
||||||
|
// VK returns user_id as a bare JSON number in some responses; it must parse too.
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, `{"user_id":1234567890,"access_token":"a"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
id, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exchange: %v", err)
|
||||||
|
}
|
||||||
|
if id.ExternalID != "1234567890" {
|
||||||
|
t.Fatalf("ExternalID = %q, want 1234567890", id.ExternalID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeFallsBackToIDTokenSub(t *testing.T) {
|
||||||
|
sub := "987654321"
|
||||||
|
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"` + sub + `"}`))
|
||||||
|
idToken := "header." + payload + ".sig"
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, `{"access_token":"a","id_token":"`+idToken+`"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
id, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exchange: %v", err)
|
||||||
|
}
|
||||||
|
if id.ExternalID != sub {
|
||||||
|
t.Fatalf("ExternalID = %q, want %q (from id_token sub)", id.ExternalID, sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeRejectedIsErrInvalid(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = io.WriteString(w, `{"error":"invalid_grant","error_description":"code expired"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
|
||||||
|
if !errors.Is(err, ErrInvalid) {
|
||||||
|
t.Fatalf("err = %v, want ErrInvalid", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeEmptyInputsFailFast(t *testing.T) {
|
||||||
|
// Missing PKCE inputs are rejected without any network call.
|
||||||
|
ex := testExchanger("http://127.0.0.1:0/never")
|
||||||
|
for _, args := range [][3]string{{"", "d", "v"}, {"c", "", "v"}, {"c", "d", ""}} {
|
||||||
|
if _, err := ex.Exchange(context.Background(), args[0], args[1], args[2]); !errors.Is(err, ErrInvalid) {
|
||||||
|
t.Errorf("Exchange%v err = %v, want ErrInvalid", args, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeTransportErrorIsNotErrInvalid(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
srv.Close() // closed listener → connection refused
|
||||||
|
_, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want a transport error")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrInvalid) {
|
||||||
|
t.Fatalf("transport error must not be ErrInvalid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -224,6 +224,12 @@ table Profile {
|
|||||||
// variant_preferences is the set of game variants the player allows themselves to be
|
// variant_preferences is the set of game variants the player allows themselves to be
|
||||||
// matched into (engine.Variant labels), Erudit-first; the New Game picker is gated by it.
|
// matched into (engine.Variant labels), Erudit-first; the New Game picker is gated by it.
|
||||||
variant_preferences:[string];
|
variant_preferences:[string];
|
||||||
|
// email is the account's confirmed email address ("" when none); telegram_linked and
|
||||||
|
// vk_linked report whether a platform identity is attached. They drive the profile's
|
||||||
|
// link / unlink / change-email controls (all added trailing — backward-compatible).
|
||||||
|
email:string;
|
||||||
|
telegram_linked:bool;
|
||||||
|
vk_linked:bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
||||||
@@ -495,6 +501,36 @@ table LinkTelegramRequest {
|
|||||||
data:string;
|
data:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinkVKRequest carries a VK ID web authorization for attaching a VK identity to the
|
||||||
|
// current account. The fields are the PKCE code-exchange inputs the frontend obtains
|
||||||
|
// from the VK ID SDK (One Tap): the authorization code, the device id issued with it,
|
||||||
|
// and the PKCE code verifier. The gateway completes the confidential code exchange
|
||||||
|
// server-side (under the app's protected key) to obtain the trusted vk user id.
|
||||||
|
table LinkVKRequest {
|
||||||
|
code:string;
|
||||||
|
device_id:string;
|
||||||
|
code_verifier:string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkUnlinkRequest detaches a platform identity (kind = "telegram" | "vk") from the
|
||||||
|
// caller's account; email is never unlinked (it is changed).
|
||||||
|
table LinkUnlinkRequest {
|
||||||
|
kind:string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountDeleteConfirm carries the account-deletion step-up proof: a mailed code (email
|
||||||
|
// accounts) or the typed phrase (platform-only accounts).
|
||||||
|
table AccountDeleteConfirm {
|
||||||
|
code:string;
|
||||||
|
phrase:string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountDeleteRequestResult reports which deletion step-up the account uses: "email"
|
||||||
|
// (a code was mailed) or "phrase" (type the confirmation phrase).
|
||||||
|
table AccountDeleteRequestResult {
|
||||||
|
method:string;
|
||||||
|
}
|
||||||
|
|
||||||
// LinkResult is the unified result of a confirm or merge step. status is "linked"
|
// LinkResult is the unified result of a confirm or merge step. status is "linked"
|
||||||
// (bound to the caller), "merge_required" (the identity belongs to another account —
|
// (bound to the caller), "merge_required" (the identity belongs to another account —
|
||||||
// the secondary_* fields summarise it for the irreversible confirmation), or
|
// the secondary_* fields summarise it for the irreversible confirmation), or
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountDeleteConfirm struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsAccountDeleteConfirm(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteConfirm {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &AccountDeleteConfirm{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishAccountDeleteConfirmBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsAccountDeleteConfirm(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteConfirm {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &AccountDeleteConfirm{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedAccountDeleteConfirmBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *AccountDeleteConfirm) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *AccountDeleteConfirm) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *AccountDeleteConfirm) Code() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *AccountDeleteConfirm) Phrase() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AccountDeleteConfirmStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(2)
|
||||||
|
}
|
||||||
|
func AccountDeleteConfirmAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0)
|
||||||
|
}
|
||||||
|
func AccountDeleteConfirmAddPhrase(builder *flatbuffers.Builder, phrase flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(phrase), 0)
|
||||||
|
}
|
||||||
|
func AccountDeleteConfirmEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountDeleteRequestResult struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsAccountDeleteRequestResult(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteRequestResult {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &AccountDeleteRequestResult{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishAccountDeleteRequestResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsAccountDeleteRequestResult(buf []byte, offset flatbuffers.UOffsetT) *AccountDeleteRequestResult {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &AccountDeleteRequestResult{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedAccountDeleteRequestResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *AccountDeleteRequestResult) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *AccountDeleteRequestResult) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *AccountDeleteRequestResult) Method() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AccountDeleteRequestResultStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(1)
|
||||||
|
}
|
||||||
|
func AccountDeleteRequestResultAddMethod(builder *flatbuffers.Builder, method flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(method), 0)
|
||||||
|
}
|
||||||
|
func AccountDeleteRequestResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LinkUnlinkRequest struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsLinkUnlinkRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkUnlinkRequest {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &LinkUnlinkRequest{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishLinkUnlinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsLinkUnlinkRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkUnlinkRequest {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &LinkUnlinkRequest{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedLinkUnlinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *LinkUnlinkRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *LinkUnlinkRequest) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *LinkUnlinkRequest) Kind() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LinkUnlinkRequestStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(1)
|
||||||
|
}
|
||||||
|
func LinkUnlinkRequestAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(kind), 0)
|
||||||
|
}
|
||||||
|
func LinkUnlinkRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LinkVKRequest struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsLinkVKRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkVKRequest {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &LinkVKRequest{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishLinkVKRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsLinkVKRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkVKRequest {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &LinkVKRequest{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedLinkVKRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *LinkVKRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *LinkVKRequest) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *LinkVKRequest) Code() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *LinkVKRequest) DeviceId() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *LinkVKRequest) CodeVerifier() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LinkVKRequestStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(3)
|
||||||
|
}
|
||||||
|
func LinkVKRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0)
|
||||||
|
}
|
||||||
|
func LinkVKRequestAddDeviceId(builder *flatbuffers.Builder, deviceId flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(deviceId), 0)
|
||||||
|
}
|
||||||
|
func LinkVKRequestAddCodeVerifier(builder *flatbuffers.Builder, codeVerifier flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(codeVerifier), 0)
|
||||||
|
}
|
||||||
|
func LinkVKRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -179,8 +179,40 @@ func (rcv *Profile) VariantPreferencesLength() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *Profile) Email() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(30))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *Profile) TelegramLinked() bool {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(32))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *Profile) MutateTelegramLinked(n bool) bool {
|
||||||
|
return rcv._tab.MutateBoolSlot(32, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *Profile) VkLinked() bool {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(34))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *Profile) MutateVkLinked(n bool) bool {
|
||||||
|
return rcv._tab.MutateBoolSlot(34, n)
|
||||||
|
}
|
||||||
|
|
||||||
func ProfileStart(builder *flatbuffers.Builder) {
|
func ProfileStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(13)
|
builder.StartObject(16)
|
||||||
}
|
}
|
||||||
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
||||||
@@ -224,6 +256,15 @@ func ProfileAddVariantPreferences(builder *flatbuffers.Builder, variantPreferenc
|
|||||||
func ProfileStartVariantPreferencesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
func ProfileStartVariantPreferencesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
return builder.StartVector(4, numElems, 4)
|
||||||
}
|
}
|
||||||
|
func ProfileAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(email), 0)
|
||||||
|
}
|
||||||
|
func ProfileAddTelegramLinked(builder *flatbuffers.Builder, telegramLinked bool) {
|
||||||
|
builder.PrependBoolSlot(14, telegramLinked, false)
|
||||||
|
}
|
||||||
|
func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) {
|
||||||
|
builder.PrependBoolSlot(15, vkLinked, false)
|
||||||
|
}
|
||||||
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
|
// The maintenance overlay is raised in prod by the edge 503 marker (X-Scrabble-Maintenance);
|
||||||
|
// the mock transport never produces one, so — like the offline indicator — the e2e drives it
|
||||||
|
// through the window.__maint hook (gateway.ts, mock-only). It is app-global (mounted outside
|
||||||
|
// the route blocks in App.svelte), so it shows without a session.
|
||||||
|
test('maintenance overlay covers the app and lifts on recovery', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||||
|
|
||||||
|
// A planned deploy window begins: a non-dismissable dimmed overlay covers the app.
|
||||||
|
await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on());
|
||||||
|
const overlay = page.getByRole('alertdialog');
|
||||||
|
await expect(overlay).toBeVisible();
|
||||||
|
// The retry button is present (EN "Try again" / RU "Повторить" depending on locale).
|
||||||
|
await expect(overlay.getByRole('button', { name: /again|Повторить/i })).toBeVisible();
|
||||||
|
|
||||||
|
// The window ends — in prod the store's poll lifts it on the first successful read; the mock
|
||||||
|
// has no probe, so it clears explicitly. The overlay disappears with no page reload.
|
||||||
|
await page.evaluate(() => (window as unknown as { __maint: { off(): void } }).__maint.off());
|
||||||
|
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovery reloads the SPA to pick up the fresh client', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on());
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Real recovery reloads the page (the deploy may have shipped an incompatible client, and the
|
||||||
|
// running bundle is the old one). Wait for the forced navigation, then confirm the app
|
||||||
|
// re-bootstrapped: the overlay is gone (the store reset by the reload) and the login is back.
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForEvent('load'),
|
||||||
|
page.evaluate(() => (window as unknown as { __maint: { recover(): void } }).__maint.recover()),
|
||||||
|
]);
|
||||||
|
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
|
||||||
|
});
|
||||||
+54
-16
@@ -292,35 +292,73 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa
|
|||||||
await expect(save).toBeEnabled();
|
await expect(save).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The email upgrade box is now shown to guests (email bind + the merge dialog). These specs
|
// The profile's sign-in-methods matrix (email add/change + provider link/unlink). The mock
|
||||||
// still assume a non-guest login and the visible Telegram control, so they stay skipped until
|
// profile is a durable account already holding an email, so the email control is the change
|
||||||
// PR2 re-enables provider linking and adds a guest-login setup.
|
// flow; a new address containing "taken" stands in (in the mock) for one owned by another
|
||||||
test.skip('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => {
|
// account, driving the non-disclosing refusal.
|
||||||
|
test('change email: a taken address is refused without disclosure', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await openProfile(page);
|
await openProfile(page);
|
||||||
|
|
||||||
// The email box is shown to guests (this spec needs a guest login — re-enabled in PR2).
|
await expect(page.getByText('you@example.com')).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: 'Link an account' })).toBeVisible();
|
await page.getByRole('button', { name: 'Change' }).click();
|
||||||
// An address containing "merge" stands in (in the mock) for one already owned by
|
await page.getByPlaceholder('New email address').fill('taken@example.com');
|
||||||
// another account, so the confirm step reveals a required merge.
|
|
||||||
await page.locator('.emailbox input[type="email"]').fill('merge@example.com');
|
|
||||||
await page.getByRole('button', { name: 'Send code' }).click();
|
await page.getByRole('button', { name: 'Send code' }).click();
|
||||||
await page.locator('.emailbox .codein').fill('123456');
|
await page.locator('.accounts .codein').fill('123456');
|
||||||
await page.getByRole('button', { name: 'OK' }).click();
|
await page.getByRole('button', { name: 'OK' }).click();
|
||||||
|
|
||||||
// The reveal happens only after the code, and names the other account.
|
// The neutral message never reveals the other account.
|
||||||
await expect(page.getByText('Merge accounts?')).toBeVisible();
|
await expect(page.getByText('Check the address or contact support.')).toBeVisible();
|
||||||
await expect(page.getByText(/Ann/)).toBeVisible();
|
await expect(page.getByText(/belongs to another account/)).toHaveCount(0);
|
||||||
await page.getByRole('button', { name: 'Merge' }).click();
|
await expect(page.getByText('you@example.com')).toBeVisible();
|
||||||
await expect(page.getByText('Merge accounts?')).toBeHidden();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => {
|
test('change email: a free address replaces the current one', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await openProfile(page);
|
await openProfile(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Change' }).click();
|
||||||
|
await page.getByPlaceholder('New email address').fill('fresh@example.com');
|
||||||
|
await page.getByRole('button', { name: 'Send code' }).click();
|
||||||
|
await page.locator('.accounts .codein').fill('123456');
|
||||||
|
await page.getByRole('button', { name: 'OK' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('fresh@example.com')).toBeVisible();
|
||||||
|
await expect(page.getByText('you@example.com')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('link then unlink Telegram from the sign-in methods', async ({ page }) => {
|
||||||
|
await loginLobby(page);
|
||||||
|
await openProfile(page);
|
||||||
|
|
||||||
|
// On the web the Telegram login-widget control is offered; the mock links it instantly.
|
||||||
|
await page.getByRole('button', { name: 'Link Telegram' }).click();
|
||||||
|
const tgRow = page.locator('.acctrow').filter({ hasText: 'Telegram' });
|
||||||
|
await expect(tgRow).toBeVisible();
|
||||||
|
|
||||||
|
// With email + Telegram two methods remain, so Unlink is offered; confirm it in the dialog.
|
||||||
|
await tgRow.getByRole('button', { name: 'Unlink' }).click();
|
||||||
|
await expect(page.getByText('Unlink account?')).toBeVisible();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Unlink' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.acctrow').filter({ hasText: 'Telegram' })).toHaveCount(0);
|
||||||
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('account deletion: the mailed-code step-up leads to the terminal deleted screen', async ({ page }) => {
|
||||||
|
await loginLobby(page);
|
||||||
|
await openProfile(page);
|
||||||
|
|
||||||
|
// The mock profile holds an email, so deletion asks for the mailed code.
|
||||||
|
await page.getByRole('button', { name: 'Delete account' }).click();
|
||||||
|
await expect(page.getByText('Delete your account?')).toBeVisible();
|
||||||
|
await page.getByRole('dialog').locator('.codein').fill('123456');
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Delete permanently' }).click();
|
||||||
|
|
||||||
|
// The app swaps to the terminal account-deleted screen.
|
||||||
|
await expect(page.getByText('Account deleted')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('chat: one message per turn — the field shows, then a caption replaces it after sending', async ({ page }) => {
|
test('chat: one message per turn — the field shows, then a caption replaces it after sending', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
|
await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||||
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||||
import Coachmark from './components/Coachmark.svelte';
|
import Coachmark from './components/Coachmark.svelte';
|
||||||
|
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
|
||||||
import DebugPanel from './components/DebugPanel.svelte';
|
import DebugPanel from './components/DebugPanel.svelte';
|
||||||
import Login from './screens/Login.svelte';
|
import Login from './screens/Login.svelte';
|
||||||
import Lobby from './screens/Lobby.svelte';
|
import Lobby from './screens/Lobby.svelte';
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
import CommsHub from './game/CommsHub.svelte';
|
import CommsHub from './game/CommsHub.svelte';
|
||||||
import Feedback from './screens/Feedback.svelte';
|
import Feedback from './screens/Feedback.svelte';
|
||||||
import Blocked from './screens/Blocked.svelte';
|
import Blocked from './screens/Blocked.svelte';
|
||||||
|
import AccountDeleted from './screens/AccountDeleted.svelte';
|
||||||
import BootError from './screens/BootError.svelte';
|
import BootError from './screens/BootError.svelte';
|
||||||
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
|
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
|
||||||
|
|
||||||
@@ -83,6 +85,8 @@
|
|||||||
<!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy):
|
<!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy):
|
||||||
show the retry screen instead of falling back to the web login. -->
|
show the retry screen instead of falling back to the web login. -->
|
||||||
<BootError />
|
<BootError />
|
||||||
|
{:else if app.accountDeleted}
|
||||||
|
<AccountDeleted />
|
||||||
{:else if app.blocked}
|
{:else if app.blocked}
|
||||||
<Blocked />
|
<Blocked />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -125,6 +129,7 @@
|
|||||||
<StaleInviteModal />
|
<StaleInviteModal />
|
||||||
<WelcomeRedeemModal />
|
<WelcomeRedeemModal />
|
||||||
<Coachmark />
|
<Coachmark />
|
||||||
|
<MaintenanceOverlay />
|
||||||
|
|
||||||
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
||||||
<Splash />
|
<Splash />
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Non-dismissable maintenance cover. Shown while the edge is in a planned deploy window
|
||||||
|
// (maintenance.svelte.ts, raised from a caddy 503 carrying X-Scrabble-Maintenance). It has
|
||||||
|
// no close affordance — an in-session user cannot dismiss it — but the store's poll lifts
|
||||||
|
// it automatically the moment the gateway answers again; the "retry" button just forces an
|
||||||
|
// immediate re-check. Mirrors the static caddy maintenance page (deploy/caddy/maintenance.html)
|
||||||
|
// so a fresh load and an in-session user see the same thing.
|
||||||
|
import { maintenance, retryNow } from '../lib/maintenance.svelte';
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if maintenance.active}
|
||||||
|
<div class="scrim" role="alertdialog" aria-modal="true" aria-labelledby="maint-title" aria-describedby="maint-body">
|
||||||
|
<div class="card">
|
||||||
|
<div class="tile" aria-hidden="true">Э</div>
|
||||||
|
<h1 id="maint-title">{t('maintenance.title')}</h1>
|
||||||
|
<p id="maint-body">{t('maintenance.body')}</p>
|
||||||
|
<button type="button" onclick={retryNow}>{t('maintenance.retry')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
/* Above the game (z 60) and toasts (z 50) — a planned window covers everything the user
|
||||||
|
could otherwise interact with; below the dev DebugPanel (z 10000). */
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
max-width: 22rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
/* Mirrors a placed board tile (as Splash.svelte / the caddy page do). */
|
||||||
|
.tile {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--tile-bg);
|
||||||
|
color: var(--tile-text);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 -2px 0 var(--tile-edge),
|
||||||
|
2px 0 3px -1px rgba(0, 0, 0, 0.4);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.55rem 1.4rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
// automatically generated by the FlatBuffers compiler, do not modify
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
export { AccountDeleteConfirm } from './scrabblefb/account-delete-confirm.js';
|
||||||
|
export { AccountDeleteRequestResult } from './scrabblefb/account-delete-request-result.js';
|
||||||
export { AccountRef } from './scrabblefb/account-ref.js';
|
export { AccountRef } from './scrabblefb/account-ref.js';
|
||||||
export { Ack } from './scrabblefb/ack.js';
|
export { Ack } from './scrabblefb/ack.js';
|
||||||
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
|
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
|
||||||
@@ -51,6 +53,8 @@ export { LinkEmailConfirm } from './scrabblefb/link-email-confirm.js';
|
|||||||
export { LinkEmailRequest } from './scrabblefb/link-email-request.js';
|
export { LinkEmailRequest } from './scrabblefb/link-email-request.js';
|
||||||
export { LinkResult } from './scrabblefb/link-result.js';
|
export { LinkResult } from './scrabblefb/link-result.js';
|
||||||
export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js';
|
export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js';
|
||||||
|
export { LinkUnlinkRequest } from './scrabblefb/link-unlink-request.js';
|
||||||
|
export { LinkVKRequest } from './scrabblefb/link-vkrequest.js';
|
||||||
export { MatchFoundEvent } from './scrabblefb/match-found-event.js';
|
export { MatchFoundEvent } from './scrabblefb/match-found-event.js';
|
||||||
export { MatchResult } from './scrabblefb/match-result.js';
|
export { MatchResult } from './scrabblefb/match-result.js';
|
||||||
export { MoveRecord } from './scrabblefb/move-record.js';
|
export { MoveRecord } from './scrabblefb/move-record.js';
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class AccountDeleteConfirm {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):AccountDeleteConfirm {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsAccountDeleteConfirm(bb:flatbuffers.ByteBuffer, obj?:AccountDeleteConfirm):AccountDeleteConfirm {
|
||||||
|
return (obj || new AccountDeleteConfirm()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsAccountDeleteConfirm(bb:flatbuffers.ByteBuffer, obj?:AccountDeleteConfirm):AccountDeleteConfirm {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new AccountDeleteConfirm()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
code():string|null
|
||||||
|
code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
code(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
phrase():string|null
|
||||||
|
phrase(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
phrase(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startAccountDeleteConfirm(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(0, codeOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addPhrase(builder:flatbuffers.Builder, phraseOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(1, phraseOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endAccountDeleteConfirm(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createAccountDeleteConfirm(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset, phraseOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
AccountDeleteConfirm.startAccountDeleteConfirm(builder);
|
||||||
|
AccountDeleteConfirm.addCode(builder, codeOffset);
|
||||||
|
AccountDeleteConfirm.addPhrase(builder, phraseOffset);
|
||||||
|
return AccountDeleteConfirm.endAccountDeleteConfirm(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class AccountDeleteRequestResult {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):AccountDeleteRequestResult {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsAccountDeleteRequestResult(bb:flatbuffers.ByteBuffer, obj?:AccountDeleteRequestResult):AccountDeleteRequestResult {
|
||||||
|
return (obj || new AccountDeleteRequestResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsAccountDeleteRequestResult(bb:flatbuffers.ByteBuffer, obj?:AccountDeleteRequestResult):AccountDeleteRequestResult {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new AccountDeleteRequestResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
method():string|null
|
||||||
|
method(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
method(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startAccountDeleteRequestResult(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addMethod(builder:flatbuffers.Builder, methodOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(0, methodOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endAccountDeleteRequestResult(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createAccountDeleteRequestResult(builder:flatbuffers.Builder, methodOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
AccountDeleteRequestResult.startAccountDeleteRequestResult(builder);
|
||||||
|
AccountDeleteRequestResult.addMethod(builder, methodOffset);
|
||||||
|
return AccountDeleteRequestResult.endAccountDeleteRequestResult(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class LinkUnlinkRequest {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):LinkUnlinkRequest {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsLinkUnlinkRequest(bb:flatbuffers.ByteBuffer, obj?:LinkUnlinkRequest):LinkUnlinkRequest {
|
||||||
|
return (obj || new LinkUnlinkRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsLinkUnlinkRequest(bb:flatbuffers.ByteBuffer, obj?:LinkUnlinkRequest):LinkUnlinkRequest {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new LinkUnlinkRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
kind():string|null
|
||||||
|
kind(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
kind(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startLinkUnlinkRequest(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(0, kindOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endLinkUnlinkRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createLinkUnlinkRequest(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
LinkUnlinkRequest.startLinkUnlinkRequest(builder);
|
||||||
|
LinkUnlinkRequest.addKind(builder, kindOffset);
|
||||||
|
return LinkUnlinkRequest.endLinkUnlinkRequest(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class LinkVKRequest {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):LinkVKRequest {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsLinkVKRequest(bb:flatbuffers.ByteBuffer, obj?:LinkVKRequest):LinkVKRequest {
|
||||||
|
return (obj || new LinkVKRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsLinkVKRequest(bb:flatbuffers.ByteBuffer, obj?:LinkVKRequest):LinkVKRequest {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new LinkVKRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
code():string|null
|
||||||
|
code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
code(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceId():string|null
|
||||||
|
deviceId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
deviceId(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
codeVerifier():string|null
|
||||||
|
codeVerifier(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
codeVerifier(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startLinkVKRequest(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(0, codeOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addDeviceId(builder:flatbuffers.Builder, deviceIdOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(1, deviceIdOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addCodeVerifier(builder:flatbuffers.Builder, codeVerifierOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, codeVerifierOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endLinkVKRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createLinkVKRequest(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset, deviceIdOffset:flatbuffers.Offset, codeVerifierOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
LinkVKRequest.startLinkVKRequest(builder);
|
||||||
|
LinkVKRequest.addCode(builder, codeOffset);
|
||||||
|
LinkVKRequest.addDeviceId(builder, deviceIdOffset);
|
||||||
|
LinkVKRequest.addCodeVerifier(builder, codeVerifierOffset);
|
||||||
|
return LinkVKRequest.endLinkVKRequest(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,8 +107,25 @@ variantPreferencesLength():number {
|
|||||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
email():string|null
|
||||||
|
email(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
email(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 30);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
telegramLinked():boolean {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 32);
|
||||||
|
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
vkLinked():boolean {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 34);
|
||||||
|
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||||
|
}
|
||||||
|
|
||||||
static startProfile(builder:flatbuffers.Builder) {
|
static startProfile(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(13);
|
builder.startObject(16);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
||||||
@@ -175,6 +192,18 @@ static startVariantPreferencesVector(builder:flatbuffers.Builder, numElems:numbe
|
|||||||
builder.startVector(4, numElems, 4);
|
builder.startVector(4, numElems, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(13, emailOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addTelegramLinked(builder:flatbuffers.Builder, telegramLinked:boolean) {
|
||||||
|
builder.addFieldInt8(14, +telegramLinked, +false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addVkLinked(builder:flatbuffers.Builder, vkLinked:boolean) {
|
||||||
|
builder.addFieldInt8(15, +vkLinked, +false);
|
||||||
|
}
|
||||||
|
|
||||||
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { startLocalEvalMetrics } from './localeval-metrics';
|
|||||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
|
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
|
||||||
import {
|
import {
|
||||||
insideTelegram,
|
insideTelegram,
|
||||||
|
telegramClose,
|
||||||
collectTelegramDiag,
|
collectTelegramDiag,
|
||||||
type TelegramDiag,
|
type TelegramDiag,
|
||||||
onTelegramPath,
|
onTelegramPath,
|
||||||
@@ -32,7 +33,8 @@ import {
|
|||||||
telegramCloudGet,
|
telegramCloudGet,
|
||||||
telegramCloudSet,
|
telegramCloudSet,
|
||||||
} from './telegram';
|
} from './telegram';
|
||||||
import { onVKPath, insideVK, vkInit, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
|
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
|
||||||
|
import { pendingVKLink, type VKLinkCallback } from './vkid';
|
||||||
import { haptic } from './haptics';
|
import { haptic } from './haptics';
|
||||||
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||||
import { parseStartParam } from './deeplink';
|
import { parseStartParam } from './deeplink';
|
||||||
@@ -92,6 +94,13 @@ export const app = $state<{
|
|||||||
/** The caller's active manual block, or null. When set, App.svelte replaces every screen with
|
/** The caller's active manual block, or null. When set, App.svelte replaces every screen with
|
||||||
* the terminal blocked screen and all push/poll is stopped. */
|
* the terminal blocked screen and all push/poll is stopped. */
|
||||||
blocked: BlockStatus | null;
|
blocked: BlockStatus | null;
|
||||||
|
/** Set once the account has been deleted: App.svelte shows the terminal "account deleted"
|
||||||
|
* screen and all push/poll is stopped. Reopening the app just creates a fresh account. */
|
||||||
|
accountDeleted: boolean;
|
||||||
|
/** A VK ID web-link authorization callback captured on boot (see lib/vkid): the browser
|
||||||
|
* returned from VK with an auth code. Profile consumes it on mount to finish the link or
|
||||||
|
* merge (a full-page redirect loses the route, so boot routes here). Null on a normal load. */
|
||||||
|
vkLinkPending: VKLinkCallback | null;
|
||||||
toast: Toast | null;
|
toast: Toast | null;
|
||||||
lastEvent: PushEvent | null;
|
lastEvent: PushEvent | null;
|
||||||
theme: ThemePref;
|
theme: ThemePref;
|
||||||
@@ -140,6 +149,8 @@ export const app = $state<{
|
|||||||
session: null,
|
session: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
blocked: null,
|
blocked: null,
|
||||||
|
accountDeleted: false,
|
||||||
|
vkLinkPending: null,
|
||||||
toast: null,
|
toast: null,
|
||||||
lastEvent: null,
|
lastEvent: null,
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
@@ -544,6 +555,39 @@ export async function applyLinkResult(r: LinkResult): Promise<void> {
|
|||||||
void persistLanguageToServer(app.locale);
|
void persistLanguageToServer(app.locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* requestDeleteAccount starts account deletion and reports which step-up the account uses:
|
||||||
|
* 'email' (a code was mailed) or 'phrase' (type the confirmation phrase).
|
||||||
|
*/
|
||||||
|
export async function requestDeleteAccount(): Promise<'email' | 'phrase'> {
|
||||||
|
return (await gateway.deleteRequest()).method;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* confirmDeleteAccount confirms deletion with the mailed code or the typed phrase. On
|
||||||
|
* success it switches to the terminal "account deleted" screen and stops all push/poll; it
|
||||||
|
* throws on a bad code/phrase so the caller can let the user retry.
|
||||||
|
*/
|
||||||
|
export async function confirmDeleteAccount(code: string, phrase: string): Promise<void> {
|
||||||
|
await gateway.deleteConfirm(code, phrase);
|
||||||
|
closeStream();
|
||||||
|
app.accountDeleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* closeDeletedApp closes the host Mini App from the terminal deleted screen (Telegram / VK).
|
||||||
|
* A plain browser has nothing to close, so it is a no-op there — the terminal screen stays.
|
||||||
|
*/
|
||||||
|
export function closeDeletedApp(): void {
|
||||||
|
if (insideTelegram()) {
|
||||||
|
telegramClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (insideVK()) {
|
||||||
|
void vkClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* syncTelegramChrome paints Telegram's header/background/bottom bar from the app's live
|
* syncTelegramChrome paints Telegram's header/background/bottom bar from the app's live
|
||||||
* theme tokens, so the surrounding chrome matches the UI. Called after the theme is applied.
|
* theme tokens, so the surrounding chrome matches the UI. Called after the theme is applied.
|
||||||
@@ -740,9 +784,19 @@ export async function bootstrap(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saved = await loadSession();
|
const saved = await loadSession();
|
||||||
|
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
|
||||||
|
// from VK; capture and clear it here (it needs the restored session to link against).
|
||||||
|
const vkcb = pendingVKLink();
|
||||||
if (saved) {
|
if (saved) {
|
||||||
await adoptSession(saved);
|
await adoptSession(saved);
|
||||||
if (router.route.name === 'login') navigate('/');
|
if (vkcb) {
|
||||||
|
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
|
||||||
|
// finishes the link or merge on mount.
|
||||||
|
app.vkLinkPending = vkcb;
|
||||||
|
navigate('/profile');
|
||||||
|
} else if (router.route.name === 'login') {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
|
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,23 @@ export interface GatewayClient {
|
|||||||
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
|
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
|
||||||
linkTelegram(data: string): Promise<LinkResult>;
|
linkTelegram(data: string): Promise<LinkResult>;
|
||||||
linkTelegramMerge(data: string): Promise<LinkResult>;
|
linkTelegramMerge(data: string): Promise<LinkResult>;
|
||||||
|
/** Link a VK identity from the web: the args are the PKCE outputs of the VK ID
|
||||||
|
* authorization callback, exchanged for the trusted vk id on the gateway. */
|
||||||
|
linkVK(code: string, deviceId: string, codeVerifier: string): Promise<LinkResult>;
|
||||||
|
linkVKMerge(code: string, deviceId: string, codeVerifier: string): Promise<LinkResult>;
|
||||||
|
/** Detach a platform identity (kind 'telegram' | 'vk') from the account; email is never
|
||||||
|
* unlinked. Returns the refreshed link result (status 'unlinked'). */
|
||||||
|
linkUnlink(kind: string): Promise<LinkResult>;
|
||||||
|
/** Change the account's confirmed email: mail a code to the new address, then confirm
|
||||||
|
* to atomically switch (status 'changed'). A new address owned by another account is
|
||||||
|
* refused without disclosure. */
|
||||||
|
changeEmailRequest(email: string): Promise<void>;
|
||||||
|
changeEmailConfirm(email: string, code: string): Promise<LinkResult>;
|
||||||
|
/** Start account deletion; the backend mails a code (method 'email') or asks for the
|
||||||
|
* typed phrase (method 'phrase'). */
|
||||||
|
deleteRequest(): Promise<{ method: 'email' | 'phrase' }>;
|
||||||
|
/** Confirm and perform account deletion with the mailed code or the typed phrase. */
|
||||||
|
deleteConfirm(code: string, phrase: string): Promise<void>;
|
||||||
|
|
||||||
// --- live stream ---
|
// --- live stream ---
|
||||||
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
|
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ import {
|
|||||||
encodeEnqueue,
|
encodeEnqueue,
|
||||||
encodeExchange,
|
encodeExchange,
|
||||||
encodeExportUrlRequest,
|
encodeExportUrlRequest,
|
||||||
|
encodeAccountDeleteConfirm,
|
||||||
|
decodeDeleteRequestResult,
|
||||||
encodeGuestLogin,
|
encodeGuestLogin,
|
||||||
|
encodeLinkUnlink,
|
||||||
|
encodeLinkVK,
|
||||||
encodeStateRequest,
|
encodeStateRequest,
|
||||||
encodeSubmitPlay,
|
encodeSubmitPlay,
|
||||||
encodeTarget,
|
encodeTarget,
|
||||||
@@ -458,6 +462,44 @@ describe('codec', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('encodes an unlink request carrying the provider kind', () => {
|
||||||
|
const r = fb.LinkUnlinkRequest.getRootAsLinkUnlinkRequest(new ByteBuffer(encodeLinkUnlink('telegram')));
|
||||||
|
expect(r.kind()).toBe('telegram');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes a VK link request carrying the PKCE code-exchange inputs', () => {
|
||||||
|
const r = fb.LinkVKRequest.getRootAsLinkVKRequest(new ByteBuffer(encodeLinkVK('the-code', 'dev-1', 'verifier-1')));
|
||||||
|
expect(r.code()).toBe('the-code');
|
||||||
|
expect(r.deviceId()).toBe('dev-1');
|
||||||
|
expect(r.codeVerifier()).toBe('verifier-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips the account-delete confirm + request-result wire', () => {
|
||||||
|
const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm(
|
||||||
|
new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')),
|
||||||
|
);
|
||||||
|
expect(c.code()).toBe('123456');
|
||||||
|
expect(c.phrase()).toBe('DELETE');
|
||||||
|
|
||||||
|
const b = new Builder(32);
|
||||||
|
const m = b.createString('email');
|
||||||
|
fb.AccountDeleteRequestResult.startAccountDeleteRequestResult(b);
|
||||||
|
fb.AccountDeleteRequestResult.addMethod(b, m);
|
||||||
|
b.finish(fb.AccountDeleteRequestResult.endAccountDeleteRequestResult(b));
|
||||||
|
expect(decodeDeleteRequestResult(b.asUint8Array()).method).toBe('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes an unlinked / changed LinkResult status straight through', () => {
|
||||||
|
for (const status of ['unlinked', 'changed'] as const) {
|
||||||
|
const b = new Builder(64);
|
||||||
|
const s = b.createString(status);
|
||||||
|
fb.LinkResult.startLinkResult(b);
|
||||||
|
fb.LinkResult.addStatus(b, s);
|
||||||
|
b.finish(fb.LinkResult.endLinkResult(b));
|
||||||
|
expect(decodeLinkResult(b.asUint8Array()).status).toBe(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('decodes a merged LinkResult carrying a switched session', () => {
|
it('decodes a merged LinkResult carrying a switched session', () => {
|
||||||
const b = new Builder(128);
|
const b = new Builder(128);
|
||||||
const token = b.createString('tok-9');
|
const token = b.createString('tok-9');
|
||||||
|
|||||||
@@ -354,6 +354,9 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
|||||||
notificationsInAppOnly: p.notificationsInAppOnly(),
|
notificationsInAppOnly: p.notificationsInAppOnly(),
|
||||||
variantPreferences: decodeVariantPreferences(p),
|
variantPreferences: decodeVariantPreferences(p),
|
||||||
banner: decodeBanner(p),
|
banner: decodeBanner(p),
|
||||||
|
email: s(p.email()),
|
||||||
|
telegramLinked: p.telegramLinked(),
|
||||||
|
vkLinked: p.vkLinked(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,6 +731,41 @@ export function encodeLinkTelegram(data: string): Uint8Array {
|
|||||||
return finish(b, fb.LinkTelegramRequest.endLinkTelegramRequest(b));
|
return finish(b, fb.LinkTelegramRequest.endLinkTelegramRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function encodeLinkUnlink(kind: string): Uint8Array {
|
||||||
|
const b = new Builder(64);
|
||||||
|
const k = b.createString(kind);
|
||||||
|
fb.LinkUnlinkRequest.startLinkUnlinkRequest(b);
|
||||||
|
fb.LinkUnlinkRequest.addKind(b, k);
|
||||||
|
return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeLinkVK(code: string, deviceId: string, codeVerifier: string): Uint8Array {
|
||||||
|
const b = new Builder(256);
|
||||||
|
const c = b.createString(code);
|
||||||
|
const d = b.createString(deviceId);
|
||||||
|
const v = b.createString(codeVerifier);
|
||||||
|
fb.LinkVKRequest.startLinkVKRequest(b);
|
||||||
|
fb.LinkVKRequest.addCode(b, c);
|
||||||
|
fb.LinkVKRequest.addDeviceId(b, d);
|
||||||
|
fb.LinkVKRequest.addCodeVerifier(b, v);
|
||||||
|
return finish(b, fb.LinkVKRequest.endLinkVKRequest(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeAccountDeleteConfirm(code: string, phrase: string): Uint8Array {
|
||||||
|
const b = new Builder(64);
|
||||||
|
const c = b.createString(code);
|
||||||
|
const p = b.createString(phrase);
|
||||||
|
fb.AccountDeleteConfirm.startAccountDeleteConfirm(b);
|
||||||
|
fb.AccountDeleteConfirm.addCode(b, c);
|
||||||
|
fb.AccountDeleteConfirm.addPhrase(b, p);
|
||||||
|
return finish(b, fb.AccountDeleteConfirm.endAccountDeleteConfirm(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeDeleteRequestResult(buf: Uint8Array): { method: 'email' | 'phrase' } {
|
||||||
|
const r = fb.AccountDeleteRequestResult.getRootAsAccountDeleteRequestResult(new ByteBuffer(buf));
|
||||||
|
return { method: (s(r.method()) || 'phrase') as 'email' | 'phrase' };
|
||||||
|
}
|
||||||
|
|
||||||
export function decodeLinkResult(buf: Uint8Array): LinkResult {
|
export function decodeLinkResult(buf: Uint8Array): LinkResult {
|
||||||
const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf));
|
const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf));
|
||||||
const sess = r.session();
|
const sess = r.session();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { GatewayClient } from './client';
|
|||||||
import { MockGateway } from './mock/client';
|
import { MockGateway } from './mock/client';
|
||||||
import { createTransport } from './transport';
|
import { createTransport } from './transport';
|
||||||
import { reportOffline, reportOnline } from './connection.svelte';
|
import { reportOffline, reportOnline } from './connection.svelte';
|
||||||
|
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
|
||||||
|
|
||||||
const isMock = import.meta.env.MODE === 'mock';
|
const isMock = import.meta.env.MODE === 'mock';
|
||||||
|
|
||||||
@@ -20,6 +21,15 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
offline: reportOffline,
|
offline: reportOffline,
|
||||||
online: reportOnline,
|
online: reportOnline,
|
||||||
};
|
};
|
||||||
|
// The mock never produces a real 503, so the e2e drives the maintenance overlay directly
|
||||||
|
// (the store's poll is inert in mock — no probe is registered — so `off` clears it).
|
||||||
|
(
|
||||||
|
window as unknown as { __maint?: { on(retryAfterMs?: number): void; off(): void; recover(): void } }
|
||||||
|
).__maint = {
|
||||||
|
on: (retryAfterMs = 15000) => reportMaintenance(retryAfterMs),
|
||||||
|
off: clearMaintenance,
|
||||||
|
recover: maintenanceRecovered,
|
||||||
|
};
|
||||||
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
||||||
// attaches a robot on a timer).
|
// attaches a robot on a timer).
|
||||||
(
|
(
|
||||||
|
|||||||
+24
-2
@@ -4,8 +4,12 @@
|
|||||||
|
|
||||||
export const en = {
|
export const en = {
|
||||||
'app.title': 'Scrabble',
|
'app.title': 'Scrabble',
|
||||||
|
'app.brand': 'Erudit',
|
||||||
'dict.loading': 'Loading…',
|
'dict.loading': 'Loading…',
|
||||||
'connection.connecting': 'Connecting…',
|
'connection.connecting': 'Connecting…',
|
||||||
|
'maintenance.title': 'Under maintenance',
|
||||||
|
'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
|
||||||
|
'maintenance.retry': 'Try again',
|
||||||
|
|
||||||
'blocked.title': 'Account blocked',
|
'blocked.title': 'Account blocked',
|
||||||
'blocked.permanent': 'Your account is blocked.',
|
'blocked.permanent': 'Your account is blocked.',
|
||||||
@@ -36,8 +40,7 @@ export const en = {
|
|||||||
'login.sendCode': 'Send code',
|
'login.sendCode': 'Send code',
|
||||||
'login.codePlaceholder': '6-digit code',
|
'login.codePlaceholder': '6-digit code',
|
||||||
'login.signIn': 'Sign in',
|
'login.signIn': 'Sign in',
|
||||||
'confirm.prompt': 'Confirm your email to finish.',
|
'confirm.busy': 'Confirming your email…',
|
||||||
'confirm.action': 'Confirm',
|
|
||||||
'confirm.linked': 'Email confirmed.',
|
'confirm.linked': 'Email confirmed.',
|
||||||
'confirm.merge': 'Almost done — finish the merge in the app.',
|
'confirm.merge': 'Almost done — finish the merge in the app.',
|
||||||
'confirm.close': 'You can close this window and return to the game.',
|
'confirm.close': 'You can close this window and return to the game.',
|
||||||
@@ -173,12 +176,31 @@ export const en = {
|
|||||||
'profile.guestLocked': 'Sign in with email to manage your profile.',
|
'profile.guestLocked': 'Sign in with email to manage your profile.',
|
||||||
'profile.linkAccount': 'Link an account',
|
'profile.linkAccount': 'Link an account',
|
||||||
'profile.linkTelegram': 'Link Telegram',
|
'profile.linkTelegram': 'Link Telegram',
|
||||||
|
'profile.linkVK': 'Link VK',
|
||||||
'profile.linked': 'Account linked.',
|
'profile.linked': 'Account linked.',
|
||||||
'profile.merged': 'Accounts merged.',
|
'profile.merged': 'Accounts merged.',
|
||||||
'profile.mergeTitle': 'Merge accounts?',
|
'profile.mergeTitle': 'Merge accounts?',
|
||||||
'profile.mergeBody': 'This identity already belongs to “{name}” ({games} games, {friends} friends).',
|
'profile.mergeBody': 'This identity already belongs to “{name}” ({games} games, {friends} friends).',
|
||||||
'profile.mergeIrreversible': 'Merging combines both accounts into this one and cannot be undone.',
|
'profile.mergeIrreversible': 'Merging combines both accounts into this one and cannot be undone.',
|
||||||
'profile.mergeConfirm': 'Merge',
|
'profile.mergeConfirm': 'Merge',
|
||||||
|
'profile.accountsTitle': 'Sign-in methods',
|
||||||
|
'profile.changeEmail': 'Change',
|
||||||
|
'profile.newEmailPlaceholder': 'New email address',
|
||||||
|
'profile.emailChanged': 'Email updated.',
|
||||||
|
'profile.emailChangeTaken': 'Check the address or contact support.',
|
||||||
|
'profile.unlink': 'Unlink',
|
||||||
|
'profile.unlinked': 'Account unlinked.',
|
||||||
|
'profile.unlinkTitle': 'Unlink account?',
|
||||||
|
'profile.unlinkBody': 'Remove {provider} as a sign-in method? You can link it again later.',
|
||||||
|
'profile.deleteAccount': 'Delete account',
|
||||||
|
'profile.deleteTitle': 'Delete your account?',
|
||||||
|
'profile.deleteWarn': 'This removes your account and frees your sign-in methods. It cannot be undone.',
|
||||||
|
'profile.deleteEmailPrompt': 'We sent a code to your email. Enter it to delete your account.',
|
||||||
|
'profile.deletePhrasePrompt': 'Type DELETE to confirm.',
|
||||||
|
'profile.deletePhrasePlaceholder': 'DELETE',
|
||||||
|
'profile.deleteConfirm': 'Delete permanently',
|
||||||
|
'deleted.title': 'Account deleted',
|
||||||
|
'deleted.body': 'Your account has been removed. Reopening the app just creates a new account.',
|
||||||
|
|
||||||
'settings.title': 'Settings',
|
'settings.title': 'Settings',
|
||||||
'settings.theme': 'Theme',
|
'settings.theme': 'Theme',
|
||||||
|
|||||||
+24
-2
@@ -5,8 +5,12 @@ import type { MessageKey } from './en';
|
|||||||
|
|
||||||
export const ru: Record<MessageKey, string> = {
|
export const ru: Record<MessageKey, string> = {
|
||||||
'app.title': 'Scrabble',
|
'app.title': 'Scrabble',
|
||||||
|
'app.brand': 'Эрудит',
|
||||||
'dict.loading': 'Загрузка…',
|
'dict.loading': 'Загрузка…',
|
||||||
'connection.connecting': 'Подключение…',
|
'connection.connecting': 'Подключение…',
|
||||||
|
'maintenance.title': 'Технические работы',
|
||||||
|
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
|
||||||
|
'maintenance.retry': 'Повторить',
|
||||||
|
|
||||||
'blocked.title': 'Учётная запись заблокирована',
|
'blocked.title': 'Учётная запись заблокирована',
|
||||||
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
||||||
@@ -37,8 +41,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'login.sendCode': 'Отправить код',
|
'login.sendCode': 'Отправить код',
|
||||||
'login.codePlaceholder': 'Код из 6 цифр',
|
'login.codePlaceholder': 'Код из 6 цифр',
|
||||||
'login.signIn': 'Войти',
|
'login.signIn': 'Войти',
|
||||||
'confirm.prompt': 'Подтвердите почту, чтобы завершить.',
|
'confirm.busy': 'Подтверждаем почту…',
|
||||||
'confirm.action': 'Подтвердить',
|
|
||||||
'confirm.linked': 'Почта подтверждена.',
|
'confirm.linked': 'Почта подтверждена.',
|
||||||
'confirm.merge': 'Почти готово — завершите объединение в приложении.',
|
'confirm.merge': 'Почти готово — завершите объединение в приложении.',
|
||||||
'confirm.close': 'Можно закрыть это окно и вернуться в игру.',
|
'confirm.close': 'Можно закрыть это окно и вернуться в игру.',
|
||||||
@@ -173,12 +176,31 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
|
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
|
||||||
'profile.linkAccount': 'Привязать аккаунт',
|
'profile.linkAccount': 'Привязать аккаунт',
|
||||||
'profile.linkTelegram': 'Привязать Telegram',
|
'profile.linkTelegram': 'Привязать Telegram',
|
||||||
|
'profile.linkVK': 'Привязать VK',
|
||||||
'profile.linked': 'Аккаунт привязан.',
|
'profile.linked': 'Аккаунт привязан.',
|
||||||
'profile.merged': 'Аккаунты объединены.',
|
'profile.merged': 'Аккаунты объединены.',
|
||||||
'profile.mergeTitle': 'Объединить аккаунты?',
|
'profile.mergeTitle': 'Объединить аккаунты?',
|
||||||
'profile.mergeBody': 'Эта личность уже принадлежит «{name}» (игр: {games}, друзей: {friends}).',
|
'profile.mergeBody': 'Эта личность уже принадлежит «{name}» (игр: {games}, друзей: {friends}).',
|
||||||
'profile.mergeIrreversible': 'Объединение сольёт оба аккаунта в этот и необратимо.',
|
'profile.mergeIrreversible': 'Объединение сольёт оба аккаунта в этот и необратимо.',
|
||||||
'profile.mergeConfirm': 'Объединить',
|
'profile.mergeConfirm': 'Объединить',
|
||||||
|
'profile.accountsTitle': 'Способы входа',
|
||||||
|
'profile.changeEmail': 'Изменить',
|
||||||
|
'profile.newEmailPlaceholder': 'Новый адрес e-mail',
|
||||||
|
'profile.emailChanged': 'E-mail изменён.',
|
||||||
|
'profile.emailChangeTaken': 'Проверьте правильность e-mail или обратитесь в поддержку.',
|
||||||
|
'profile.unlink': 'Отвязать',
|
||||||
|
'profile.unlinked': 'Аккаунт отвязан.',
|
||||||
|
'profile.unlinkTitle': 'Отвязать аккаунт?',
|
||||||
|
'profile.unlinkBody': 'Убрать {provider} как способ входа? Позже можно привязать снова.',
|
||||||
|
'profile.deleteAccount': 'Удалить аккаунт',
|
||||||
|
'profile.deleteTitle': 'Удалить аккаунт?',
|
||||||
|
'profile.deleteWarn': 'Аккаунт будет удалён, а способы входа освобождены. Отменить нельзя.',
|
||||||
|
'profile.deleteEmailPrompt': 'Мы отправили код на вашу почту. Введите его, чтобы удалить аккаунт.',
|
||||||
|
'profile.deletePhrasePrompt': 'Введите DELETE для подтверждения.',
|
||||||
|
'profile.deletePhrasePlaceholder': 'DELETE',
|
||||||
|
'profile.deleteConfirm': 'Удалить навсегда',
|
||||||
|
'deleted.title': 'Учётная запись удалена',
|
||||||
|
'deleted.body': 'Аккаунт удалён. Если снова откроете приложение — будет создан новый аккаунт.',
|
||||||
|
|
||||||
'settings.title': 'Настройки',
|
'settings.title': 'Настройки',
|
||||||
'settings.theme': 'Тема',
|
'settings.theme': 'Тема',
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Global maintenance signal + self-clearing poll. `active` is true while the edge is in a
|
||||||
|
// planned deploy window (a caddy 503 carried `X-Scrabble-Maintenance`; see maintenance.ts).
|
||||||
|
// A non-dismissable overlay (MaintenanceOverlay.svelte) covers the app while active, and a
|
||||||
|
// cheap read is retried on a capped-backoff cadence (mirroring connection.svelte.ts) — the
|
||||||
|
// first success lifts the overlay, so it can never get stuck even with no other traffic.
|
||||||
|
// Mock mode never reports maintenance, so it simply stays inactive.
|
||||||
|
|
||||||
|
import { backoffMs } from './retry';
|
||||||
|
|
||||||
|
let active = $state(false);
|
||||||
|
let retryHintMs = $state(0);
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let attempt = 0;
|
||||||
|
let probing = false;
|
||||||
|
let probe: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
|
export const maintenance = {
|
||||||
|
/** active is true while the edge is in a planned maintenance window. */
|
||||||
|
get active(): boolean {
|
||||||
|
return active;
|
||||||
|
},
|
||||||
|
/** retryHintMs is the last Retry-After hint in milliseconds (0 when unknown). */
|
||||||
|
get retryHintMs(): number {
|
||||||
|
return retryHintMs;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** registerMaintenanceProbe installs the reachability read the poll fires while active
|
||||||
|
* (the transport wires a cheap authenticated read). */
|
||||||
|
export function registerMaintenanceProbe(fn: () => Promise<void>): void {
|
||||||
|
probe = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** reportMaintenance raises the overlay and starts the self-clearing poll. Idempotent: a
|
||||||
|
* repeat hit only refreshes the hint, it never restarts a running poll. */
|
||||||
|
export function reportMaintenance(retryAfterMs: number): void {
|
||||||
|
retryHintMs = retryAfterMs;
|
||||||
|
if (active) return;
|
||||||
|
active = true;
|
||||||
|
attempt = 0;
|
||||||
|
if (probe) schedulePoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** clearMaintenance lowers the overlay and stops the poll without reloading (a reset, or the
|
||||||
|
* e2e hook). Prod recovery goes through maintenanceRecovered instead. */
|
||||||
|
export function clearMaintenance(): void {
|
||||||
|
active = false;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** maintenanceRecovered runs when the edge answers again after a window we were showing.
|
||||||
|
* The deploy that ended the window may have shipped a client-incompatible change (a wire /
|
||||||
|
* schema bump), and the running bundle is the OLD one — so reload to pick up the fresh
|
||||||
|
* client instead of merely hiding the overlay. A no-op unless the overlay was up; the cover
|
||||||
|
* stays over the brief reload flash. In prod recovery is the only way the overlay clears. */
|
||||||
|
export function maintenanceRecovered(): void {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
if (typeof location !== 'undefined') location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** retryNow forces an immediate re-check (the overlay's button). It never closes the
|
||||||
|
* overlay itself — only a successful probe does. A no-op while a probe is in flight. */
|
||||||
|
export function retryNow(): void {
|
||||||
|
if (!active || !probe || probing) return;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
attempt = 0;
|
||||||
|
runProbe();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePoll(): void {
|
||||||
|
pollTimer = setTimeout(runProbe, backoffMs(++attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runProbe(): void {
|
||||||
|
pollTimer = null;
|
||||||
|
if (!active || !probe || probing) return;
|
||||||
|
probing = true;
|
||||||
|
probe().then(
|
||||||
|
() => {
|
||||||
|
probing = false;
|
||||||
|
maintenanceRecovered();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
probing = false;
|
||||||
|
if (active) schedulePoll();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Code, ConnectError } from '@connectrpc/connect';
|
||||||
|
import { maintenanceRetryMs, parseRetryAfterMs } from './maintenance';
|
||||||
|
|
||||||
|
// A raw ConnectError as connect-web builds it from a non-Connect HTTP 503: the response
|
||||||
|
// headers are preserved as `.metadata` (verified against @connectrpc/connect-web).
|
||||||
|
const err503 = (headers: Record<string, string>): ConnectError =>
|
||||||
|
new ConnectError('HTTP 503', Code.Unavailable, new Headers(headers));
|
||||||
|
|
||||||
|
describe('maintenanceRetryMs', () => {
|
||||||
|
it('detects the marker and parses Retry-After', () => {
|
||||||
|
expect(maintenanceRetryMs(err503({ 'x-scrabble-maintenance': '1', 'retry-after': '120' }))).toBe(120_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the header case-insensitively, defaulting the delay when Retry-After is absent', () => {
|
||||||
|
expect(maintenanceRetryMs(err503({ 'X-Scrabble-Maintenance': '1' }))).toBe(15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for a 503 without the marker (a plain transient failure)', () => {
|
||||||
|
expect(maintenanceRetryMs(err503({ 'retry-after': '30' }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-Connect errors', () => {
|
||||||
|
expect(maintenanceRetryMs(new Error('boom'))).toBeNull();
|
||||||
|
expect(maintenanceRetryMs(undefined)).toBeNull();
|
||||||
|
expect(maintenanceRetryMs('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRetryAfterMs', () => {
|
||||||
|
it('clamps into the 3s–120s band', () => {
|
||||||
|
expect(parseRetryAfterMs('120')).toBe(120_000);
|
||||||
|
expect(parseRetryAfterMs('1')).toBe(3_000); // floor
|
||||||
|
expect(parseRetryAfterMs('9999')).toBe(120_000); // ceiling
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default on absent / garbage / non-positive', () => {
|
||||||
|
expect(parseRetryAfterMs(null)).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs(undefined)).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs('soon')).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs('0')).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs('-5')).toBe(15_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user