Compare commits
140 Commits
f1a12c2f44
...
v1.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 780ff68ec2 | |||
| ca48b3e18e | |||
| a7f483792f | |||
| 80cc2a76e8 | |||
| 1e087be90a | |||
| 84d0385c95 | |||
| 0ed34c7720 | |||
| 52d0c559f9 | |||
| beda6ccd3d | |||
| c1ac77bc6a | |||
| cafe67e9c8 | |||
| 3adc4e32c9 | |||
| 903de7d41d | |||
| baa9961c3e | |||
| 66278e34b7 | |||
| 452a686e07 | |||
| 4ddc690c38 | |||
| 6216359c6f | |||
| 8c67d679d9 | |||
| 69673e7727 | |||
| d7f3d93c6c | |||
| 16cd3d0411 | |||
| ef8a32bb82 | |||
| 53311cbc95 | |||
| 7fc1301b31 | |||
| ee7ff06403 | |||
| 537a265409 | |||
| 8c991429c6 | |||
| 359af83c34 | |||
| ff486c80f8 | |||
| 42a6308261 | |||
| 08792dad3d | |||
| e9f4cb0178 | |||
| 8fbbb3c5ef | |||
| 30770a759b | |||
| 05c445e4da | |||
| d43a740eb6 | |||
| 350013acd9 | |||
| 3ad66f49c7 | |||
| 09c5a5e72e | |||
| fffc6030ce | |||
| fc8143758a | |||
| e0d28733ff | |||
| ccd65f61b8 | |||
| 5bc2ad3b6d | |||
| 2a4b0fb25e | |||
| 2a7c632840 | |||
| 3a72bc29ba | |||
| 020742fad3 | |||
| fd225564a3 | |||
| fdf14d5897 | |||
| 1a456c4847 | |||
| 2a045a5b37 | |||
| 19e7ea5da0 | |||
| 54d701fd8a | |||
| ec0c13bebc | |||
| 5643c8be10 | |||
| 2f70ef1b85 | |||
| ef832b823d | |||
| 99f0a545be | |||
| 1a95a5f2cb | |||
| 8abd8b2ae6 | |||
| 2f867b8e6c | |||
| a692024b4e | |||
| 8349e222fc | |||
| 68ecd881d9 | |||
| d80d28a402 | |||
| 1654131904 | |||
| 9ecbe480db | |||
| 32298595f2 | |||
| 2847412b5b | |||
| 4fa77bf82c | |||
| afa44d41b4 | |||
| e4cf143e9f | |||
| 543cbe56b9 | |||
| a9c8f1ecfe | |||
| d694ead7b6 | |||
| 8c5995c076 | |||
| cedc9ffae1 | |||
| c334a9d7b7 | |||
| 57ff2d03f8 | |||
| 3a85f64726 | |||
| 00bb66ba0f | |||
| a2c79c149a | |||
| 061366da5a | |||
| d19609f87d | |||
| 51147a1429 | |||
| a9d0986e74 | |||
| d61ba68e9b | |||
| 6db9178449 | |||
| ac383880b7 | |||
| 16349f5ad9 | |||
| 101a3f118c | |||
| 400b6ac2a5 | |||
| fb7490f1df | |||
| c1805e5b7c | |||
| 45957bdcd6 | |||
| fcedadcb5b | |||
| 2feb638329 | |||
| 4dfedd02a3 | |||
| 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 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
+96
-23
@@ -26,12 +26,12 @@ on:
|
||||
push:
|
||||
branches: [development]
|
||||
|
||||
# The dictionary release the test suite validates against — the current
|
||||
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
|
||||
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
||||
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
||||
# The dictionary release. One Gitea variable is the single source of truth: the
|
||||
# test suite validates against it here (inherited by the unit/integration jobs) and
|
||||
# both contours' deploy jobs seed a fresh volume with the same value. A release bump
|
||||
# is one edit (the variable). See deploy/README.md.
|
||||
env:
|
||||
DICT_VERSION: v1.3.1
|
||||
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||
|
||||
jobs:
|
||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||
@@ -214,9 +214,21 @@ jobs:
|
||||
run: pnpm exec playwright install chromium webkit
|
||||
timeout-minutes: 5
|
||||
|
||||
# The offline e2e plays a real local vs_ai game, so it needs the per-variant dawgs; fetch the
|
||||
# release the same way the Go jobs do and point the mock preview's copy step (scripts/
|
||||
# e2e-dict.mjs, via E2E_DICT_DIR below) at it.
|
||||
- name: Fetch dictionary DAWGs
|
||||
run: |
|
||||
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
||||
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
||||
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
||||
ls -la "${GITHUB_WORKSPACE}/dawg"
|
||||
|
||||
- name: E2E smoke (mock)
|
||||
run: pnpm run test:e2e
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
E2E_DICT_DIR: ${{ github.workspace }}/dawg
|
||||
|
||||
# conformance proves the client's local move preview (the ported dawg reader +
|
||||
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
|
||||
@@ -252,6 +264,7 @@ jobs:
|
||||
run: |
|
||||
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
|
||||
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
|
||||
go run ./backend/cmd/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -271,6 +284,7 @@ jobs:
|
||||
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
||||
DICT_GOLD_DIR: /tmp/dictgold
|
||||
DICT_VALID_DIR: /tmp/validgold
|
||||
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
|
||||
run: pnpm exec vitest run src/lib/dict/
|
||||
|
||||
# gate is the single branch-protection required check. It always runs and passes
|
||||
@@ -329,24 +343,42 @@ jobs:
|
||||
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
||||
# 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.
|
||||
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).
|
||||
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
||||
# Transactional email via the shared Selectel relay. Empty host leaves the
|
||||
# backend on the log mailer (email disabled) but the contour still boots.
|
||||
SMTP_RELAY_USER: ${{ secrets.TEST_SMTP_RELAY_USER }}
|
||||
SMTP_RELAY_PASS: ${{ secrets.TEST_SMTP_RELAY_PASS }}
|
||||
SMTP_RELAY_HOST: ${{ vars.TEST_SMTP_RELAY_HOST }}
|
||||
SMTP_RELAY_PORT: ${{ vars.TEST_SMTP_RELAY_PORT }}
|
||||
SMTP_RELAY_TLS: ${{ vars.TEST_SMTP_RELAY_TLS }}
|
||||
# Transactional email via the shared Selectel relay: one account for every
|
||||
# contour -> unprefixed host/port/tls/user/pass. Empty host leaves the backend
|
||||
# on the log mailer (email disabled) but the contour still boots.
|
||||
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.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);
|
||||
# required by the backend whenever SMTP_RELAY_HOST is set.
|
||||
PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }}
|
||||
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
||||
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_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
|
||||
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||
@@ -359,12 +391,16 @@ jobs:
|
||||
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||
VITE_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }}
|
||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
||||
# VK Mini App landing link + VK ID "Web" app id: one value each serves every
|
||||
# contour -> unprefixed. VITE_VK_APP_ID also feeds the gateway (GATEWAY_VK_ID_APP_ID);
|
||||
# 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_USER: ${{ vars.TEST_POSTGRES_USER }}
|
||||
DICT_VERSION: ${{ vars.TEST_DICT_VERSION }}
|
||||
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
|
||||
run: |
|
||||
# Seed the config files to a stable host path. The runner checks out into
|
||||
@@ -375,8 +411,34 @@ jobs:
|
||||
conf="$HOME/.scrabble-deploy"
|
||||
rm -rf "$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"
|
||||
# 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
|
||||
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
|
||||
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
|
||||
@@ -389,12 +451,23 @@ jobs:
|
||||
# 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.
|
||||
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
|
||||
# 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
|
||||
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
|
||||
# pick up the fresh config.
|
||||
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
|
||||
# changed Grafana dashboard is ignored — force-recreate them to pick up the fresh
|
||||
# config. (Caddy was already recreated above so it would carry the maintenance flag.)
|
||||
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
|
||||
run: |
|
||||
|
||||
@@ -40,12 +40,22 @@ jobs:
|
||||
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
|
||||
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||
VITE_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }}
|
||||
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
|
||||
# VK Mini App link + VK ID "Web" app id: one value each serves every contour.
|
||||
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 }}
|
||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
||||
# `docker compose build` interpolates the WHOLE compose file, so every :?-guarded
|
||||
# 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:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -59,6 +69,11 @@ jobs:
|
||||
working-directory: deploy
|
||||
run: |
|
||||
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 bot separately, since it is profiled out of the prod compose.
|
||||
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 }}
|
||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||
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).
|
||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||
# Transactional email via the shared Selectel relay (confirm-codes).
|
||||
SMTP_RELAY_USER: ${{ secrets.PROD_SMTP_RELAY_USER }}
|
||||
SMTP_RELAY_PASS: ${{ secrets.PROD_SMTP_RELAY_PASS }}
|
||||
SMTP_RELAY_HOST: ${{ vars.PROD_SMTP_RELAY_HOST }}
|
||||
SMTP_RELAY_PORT: ${{ vars.PROD_SMTP_RELAY_PORT }}
|
||||
SMTP_RELAY_TLS: ${{ vars.PROD_SMTP_RELAY_TLS }}
|
||||
# Transactional email via the shared Selectel relay (confirm-codes): one account for
|
||||
# every contour -> unprefixed host/port/tls/user/pass.
|
||||
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 }}
|
||||
# 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 }}
|
||||
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
||||
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
|
||||
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
||||
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -132,33 +164,8 @@ jobs:
|
||||
run: |
|
||||
umask 077
|
||||
mkdir -p stage/certs-main
|
||||
cat > stage/env.sh <<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='$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
|
||||
# Shared with prod-rollback so the two paths render an identical runtime env.
|
||||
APP_VERSION="$TAG" bash deploy/write-prod-env.sh stage/env.sh
|
||||
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_KEY" > stage/certs-main/gateway.key
|
||||
@@ -169,7 +176,7 @@ jobs:
|
||||
ssh_main 'mkdir -p /opt/scrabble/compose'
|
||||
tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \
|
||||
| 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 -'
|
||||
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 -'
|
||||
@@ -197,7 +204,8 @@ jobs:
|
||||
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
||||
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
||||
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_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
||||
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||
@@ -214,20 +222,8 @@ jobs:
|
||||
run: |
|
||||
umask 077
|
||||
mkdir -p stage/certs-bot
|
||||
cat > stage/env.bot.sh <<EOF
|
||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
||||
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
|
||||
# Shared with prod-rollback so the two paths render an identical bot env.
|
||||
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TAG" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
|
||||
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_KEY" > stage/certs-bot/bot.key
|
||||
|
||||
@@ -51,13 +51,32 @@ jobs:
|
||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
||||
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
|
||||
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
||||
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
||||
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 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -89,24 +108,9 @@ jobs:
|
||||
run: |
|
||||
umask 077
|
||||
mkdir -p stage/certs-main
|
||||
cat > stage/env.sh <<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='$TARGET'
|
||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
||||
EOF
|
||||
# Same writer as prod-deploy's deploy-main -> the rollback re-renders the FULL
|
||||
# runtime env (not a subset), so email / VK login / Grafana alerts survive it.
|
||||
APP_VERSION="$TARGET" bash deploy/write-prod-env.sh stage/env.sh
|
||||
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_KEY" > stage/certs-main/gateway.key
|
||||
@@ -147,9 +151,11 @@ jobs:
|
||||
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
||||
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
||||
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_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_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||
steps:
|
||||
@@ -163,19 +169,8 @@ jobs:
|
||||
run: |
|
||||
umask 077
|
||||
mkdir -p stage/certs-bot
|
||||
cat > stage/env.bot.sh <<EOF
|
||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
||||
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
|
||||
# Same writer as prod-deploy's deploy-bot (parity; includes TELEGRAM_SUPPORT_CHAT_ID).
|
||||
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TARGET" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
|
||||
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_KEY" > stage/certs-bot/bot.key
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/accountmerge"
|
||||
"scrabble/backend/internal/adminalert"
|
||||
"scrabble/backend/internal/ads"
|
||||
"scrabble/backend/internal/banview"
|
||||
"scrabble/backend/internal/config"
|
||||
@@ -44,6 +45,10 @@ import (
|
||||
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
|
||||
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() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -207,6 +212,16 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
|
||||
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 != "" {
|
||||
// The alert digest carries no admin-console link on purpose — an admin URL must not
|
||||
// travel in an email (a mail provider could cache or index it); see adminalert.New.
|
||||
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, 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
|
||||
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
||||
// substitutes a pooled robot for a missing human after the wait window.
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
// Command movegen emits golden conformance fixtures for the client-side move
|
||||
// generator port (ui/src/lib/dict). It is a dev tool, run by hand; its output is
|
||||
// committed so the TypeScript parity tests run without a Go toolchain.
|
||||
//
|
||||
// For each small sample dictionary (English and Russian — the latter reaches
|
||||
// alphabet index 32, exercising the 33-letter cross-set boundary) it writes:
|
||||
//
|
||||
// - sample_<tag>.dawg the serialized dictionary (the reader/cursor fixture)
|
||||
// - sample_<tag>.words.json the stored words + their alphabet indexes
|
||||
// - sample_<tag>.gen.json ranked move-generation results from the real solver,
|
||||
// for a handful of positions, plus the ruleset the TS
|
||||
// side rebuilds to score identically
|
||||
//
|
||||
// Positions are built with only the solver's public API: an empty board, and
|
||||
// two-ply positions reached by applying the solver's own top move (so no internal
|
||||
// encoding is needed). Regenerate with:
|
||||
//
|
||||
// go run ./backend/cmd/movegen -out ui/src/lib/dict/testdata
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
)
|
||||
|
||||
// sampleWordsEN is the English sample dictionary, in strictly increasing
|
||||
// alphabet-index order (the builder requires it). Shared prefixes (car/care/cars),
|
||||
// shared suffixes (cats/dogs), internal-final nodes (do, an) and a one-letter word.
|
||||
var sampleWordsEN = []string{
|
||||
"a", "an", "and", "ant",
|
||||
"car", "care", "cared", "cares", "cars", "cat", "cats",
|
||||
"do", "doe", "does", "dog", "dogs", "done", "dot",
|
||||
}
|
||||
|
||||
// sampleWordsRU is the Russian sample dictionary, in strictly increasing index
|
||||
// order. It deliberately includes words starting with я (index 32) so the ported
|
||||
// cross-set handles alphabet indexes past JS's 31-bit shift boundary.
|
||||
var sampleWordsRU = []string{"ад", "ар", "оса", "я", "яд", "яр"}
|
||||
|
||||
// sampleFixture is the JSON committed with the .dawg so the TypeScript cursor test
|
||||
// knows the exact word set (as alphabet indexes) to expect from enumeration.
|
||||
type sampleFixture struct {
|
||||
Alphabet string `json:"alphabet"`
|
||||
NumAdded int `json:"numAdded"`
|
||||
Words []string `json:"words"`
|
||||
Indexes [][]int `json:"indexes"`
|
||||
}
|
||||
|
||||
// genFixture is the move-generation golden set for one sample dictionary.
|
||||
type genFixture struct {
|
||||
Ruleset genRuleset `json:"ruleset"`
|
||||
Cases []genCase `json:"cases"`
|
||||
}
|
||||
|
||||
// genRuleset is the scoring data the TS side rebuilds so evaluate() matches the Go
|
||||
// solver: letter values, premium multipliers per square, the centre, rack size and bonus.
|
||||
type genRuleset struct {
|
||||
Size int `json:"size"`
|
||||
Cols int `json:"cols"`
|
||||
Center int `json:"center"`
|
||||
RackSize int `json:"rackSize"`
|
||||
Bingo int `json:"bingo"`
|
||||
Values []int `json:"values"`
|
||||
LetterMult [][]int `json:"letterMult"`
|
||||
WordMult [][]int `json:"wordMult"`
|
||||
}
|
||||
|
||||
// genTile is one placed tile (a board tile or a move placement).
|
||||
type genTile struct {
|
||||
Row int `json:"row"`
|
||||
Col int `json:"col"`
|
||||
Letter int `json:"letter"`
|
||||
Blank bool `json:"blank"`
|
||||
}
|
||||
|
||||
// genRack is a rack as a multiset of letter indexes plus a blank count.
|
||||
type genRack struct {
|
||||
Letters []int `json:"letters"`
|
||||
Blanks int `json:"blanks"`
|
||||
}
|
||||
|
||||
// genMove is one ranked generated play: its orientation, placed tiles and total score.
|
||||
type genMove struct {
|
||||
Dir int `json:"dir"`
|
||||
Tiles []genTile `json:"tiles"`
|
||||
Score int `json:"score"`
|
||||
}
|
||||
|
||||
// genCase is one generation position: the tiles already on the board (empty when
|
||||
// none), the rack, the mode/rule and the ranked moves the solver returns.
|
||||
type genCase struct {
|
||||
Name string `json:"name"`
|
||||
Placed []genTile `json:"placed"`
|
||||
Rack genRack `json:"rack"`
|
||||
Mode int `json:"mode"`
|
||||
IgnoreCrossWords bool `json:"ignoreCrossWords"`
|
||||
Moves []genMove `json:"moves"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures")
|
||||
dawgDir := flag.String("dawg-dir", "", "when set, emit real-dictionary move-gen golden from the .dawg files in this dir (conformance mode) instead of the committed samples")
|
||||
flag.Parse()
|
||||
|
||||
if err := os.MkdirAll(*out, 0o755); err != nil {
|
||||
log.Fatalf("movegen: mkdir %s: %v", *out, err)
|
||||
}
|
||||
|
||||
if *dawgDir != "" {
|
||||
buildReal(*dawgDir, *out)
|
||||
return
|
||||
}
|
||||
|
||||
emitRulesets()
|
||||
|
||||
buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
|
||||
emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
|
||||
emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false),
|
||||
emptyCase("empty-blank", englishRack("caret", 1), scrabble.Both, false),
|
||||
emptyCase("empty-single-word", englishRack("caredts", 0), scrabble.Both, true),
|
||||
})
|
||||
|
||||
buildSample(*out, "ru", rules.RussianScrabble(), sampleWordsRU, []genCase{
|
||||
emptyCase("empty-yad", russianRack("ядрасо", 0), scrabble.Both, false),
|
||||
})
|
||||
}
|
||||
|
||||
// buildSample writes the dawg, the word fixture and the generation golden set for
|
||||
// one sample dictionary. Two-ply cases are appended: the solver's own top move from
|
||||
// the first non-empty result is applied, then generation runs again on the new rack.
|
||||
func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []genCase) {
|
||||
idx := rs.Alphabet
|
||||
b := dawg.New(idx)
|
||||
indexes := make([][]int, 0, len(words))
|
||||
for _, w := range words {
|
||||
if err := b.Add(w); err != nil {
|
||||
log.Fatalf("movegen[%s]: add %q: %v", tag, w, err)
|
||||
}
|
||||
enc, err := idx.Encode(w)
|
||||
if err != nil {
|
||||
log.Fatalf("movegen[%s]: encode %q: %v", tag, w, err)
|
||||
}
|
||||
ints := make([]int, len(enc))
|
||||
for i, x := range enc {
|
||||
ints[i] = int(x)
|
||||
}
|
||||
indexes = append(indexes, ints)
|
||||
}
|
||||
finder := b.Finish()
|
||||
|
||||
writeJSON(filepath.Join(out, "sample_"+tag+".words.json"), sampleFixture{
|
||||
Alphabet: tag, NumAdded: finder.NumAdded(), Words: words, Indexes: indexes,
|
||||
})
|
||||
dawgPath := filepath.Join(out, "sample_"+tag+".dawg")
|
||||
if _, err := finder.Save(dawgPath); err != nil {
|
||||
log.Fatalf("movegen[%s]: save %s: %v", tag, dawgPath, err)
|
||||
}
|
||||
|
||||
s := scrabble.NewSolver(rs, finder)
|
||||
for i := range cases {
|
||||
runCase(s, rs, &cases[i], nil)
|
||||
}
|
||||
// A two-ply position from the first standard case that produced a move.
|
||||
if two := twoPly(s, rs, cases); two != nil {
|
||||
cases = append(cases, *two)
|
||||
}
|
||||
|
||||
writeJSON(filepath.Join(out, "sample_"+tag+".gen.json"), genFixture{
|
||||
Ruleset: rulesetOf(rs), Cases: cases,
|
||||
})
|
||||
log.Printf("movegen[%s]: %d words, %d cases", tag, finder.NumAdded(), len(cases))
|
||||
}
|
||||
|
||||
// realVariant maps a shipped dictionary file to the ruleset that scores it and the racks
|
||||
// the conformance positions use. smallRack/blankRack keep the first-move (empty board)
|
||||
// lists bounded on a dense dictionary; fullRack drives a deep 7-tile mid-game position,
|
||||
// kept small by the anchors around the already-placed word.
|
||||
type realVariant struct {
|
||||
file, variant, smallRack, blankRack, fullRack string
|
||||
rs *rules.Ruleset
|
||||
}
|
||||
|
||||
// buildReal emits move-generation golden from the real shipped dictionaries in dawgDir —
|
||||
// the full alphabets and deep graphs the tiny samples cannot reach — one
|
||||
// <variant>.movegen.json per variant. Like the dictgen/validategen vectors it is
|
||||
// regenerated in CI and never committed, so it pins no dictionary version into the repo.
|
||||
func buildReal(dawgDir, out string) {
|
||||
reals := []realVariant{
|
||||
{"en_sowpods", "scrabble_en", "aine", "ain", "aeinrst", rules.English()},
|
||||
{"ru_scrabble", "scrabble_ru", "аеин", "аен", "аеиноср", rules.RussianScrabble()},
|
||||
{"ru_erudit", "erudit_ru", "аеин", "аен", "аеиноср", rules.Erudit()},
|
||||
}
|
||||
for _, v := range reals {
|
||||
data, err := os.ReadFile(filepath.Join(dawgDir, v.file+".dawg"))
|
||||
if err != nil {
|
||||
log.Fatalf("movegen[%s]: read dawg: %v", v.variant, err)
|
||||
}
|
||||
finder, err := dawg.Read(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
log.Fatalf("movegen[%s]: parse dawg: %v", v.variant, err)
|
||||
}
|
||||
s := scrabble.NewSolver(v.rs, finder)
|
||||
|
||||
cases := []genCase{
|
||||
emptyCase("first-move", encRack(v.rs, v.smallRack, 0), scrabble.Both, false),
|
||||
emptyCase("first-move-blank", encRack(v.rs, v.blankRack, 1), scrabble.Both, false),
|
||||
}
|
||||
for i := range cases {
|
||||
runCase(s, v.rs, &cases[i], nil)
|
||||
}
|
||||
// A deep 7-tile mid-game: place the top first move, then generate again. The
|
||||
// anchors around the placed word bound the list while still exercising a full rack,
|
||||
// deep left/right extension and wide cross-sets over the real graph.
|
||||
full := encRack(v.rs, v.fullRack, 0)
|
||||
b := board.New(v.rs.Rows, v.rs.Cols)
|
||||
if m1 := s.GenerateMovesOpts(b, toRack(v.rs.Size(), full), scrabble.Both, scrabble.PlayOptions{}); len(m1) > 0 {
|
||||
mid := genCase{Name: "mid-game", Rack: full, Mode: int(scrabble.Both)}
|
||||
runCase(s, v.rs, &mid, tilesOf(m1[0].Tiles))
|
||||
cases = append(cases, mid)
|
||||
}
|
||||
|
||||
writeJSON(filepath.Join(out, v.variant+".movegen.json"), genFixture{Ruleset: rulesetOf(v.rs), Cases: cases})
|
||||
total := 0
|
||||
for _, c := range cases {
|
||||
total += len(c.Moves)
|
||||
}
|
||||
_ = finder.Close()
|
||||
log.Printf("movegen[%s]: %d cases, %d golden moves", v.variant, len(cases), total)
|
||||
}
|
||||
}
|
||||
|
||||
// encRack encodes a rack given as the variant's letters (plus a blank count) into the
|
||||
// index-based genRack the fixtures carry.
|
||||
func encRack(rs *rules.Ruleset, letters string, blanks int) genRack {
|
||||
enc, err := rs.Alphabet.Encode(letters)
|
||||
if err != nil {
|
||||
log.Fatalf("movegen: encode rack %q: %v", letters, err)
|
||||
}
|
||||
idx := make([]int, len(enc))
|
||||
for i, b := range enc {
|
||||
idx[i] = int(b)
|
||||
}
|
||||
return genRack{Letters: idx, Blanks: blanks}
|
||||
}
|
||||
|
||||
// runCase fills a case's Moves by generating on a board holding the given placed
|
||||
// tiles (nil = empty board).
|
||||
func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) {
|
||||
bd := board.New(rs.Rows, rs.Cols)
|
||||
for _, t := range placed {
|
||||
bd.Set(t.Row, t.Col, cellByte(t.Letter, t.Blank))
|
||||
}
|
||||
c.Placed = placed
|
||||
rk := toRack(rs.Size(), c.Rack)
|
||||
moves := s.GenerateMovesOpts(bd, rk, scrabble.Mode(c.Mode), scrabble.PlayOptions{IgnoreCrossWords: c.IgnoreCrossWords})
|
||||
c.Moves = movesOf(moves)
|
||||
}
|
||||
|
||||
// twoPly reaches a mid-game position by applying the top move of the first standard
|
||||
// case that generated one, then generates again with a fresh rack of the same tiles.
|
||||
func twoPly(s *scrabble.Solver, rs *rules.Ruleset, cases []genCase) *genCase {
|
||||
for _, c := range cases {
|
||||
if c.IgnoreCrossWords || len(c.Moves) == 0 {
|
||||
continue
|
||||
}
|
||||
placed := c.Moves[0].Tiles
|
||||
next := genCase{Name: "two-ply", Rack: c.Rack, Mode: int(scrabble.Both)}
|
||||
runCase(s, rs, &next, placed)
|
||||
return &next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cellByte encodes a board cell the way internal/encoding.Cell does (bits 0-5 hold
|
||||
// letter+1, bit 7 marks a blank). Duplicated here because that package is internal
|
||||
// to the solver module and cannot be imported.
|
||||
func cellByte(letter int, blank bool) byte {
|
||||
v := byte(letter+1) & 0x3f
|
||||
if blank {
|
||||
v |= 0x80
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func toRack(size int, r genRack) rack.Rack {
|
||||
rk := rack.New(size)
|
||||
for _, l := range r.Letters {
|
||||
rk.Add(byte(l))
|
||||
}
|
||||
for i := 0; i < r.Blanks; i++ {
|
||||
rk.AddBlank()
|
||||
}
|
||||
return rk
|
||||
}
|
||||
|
||||
func rulesetOf(rs *rules.Ruleset) genRuleset {
|
||||
lm := make([][]int, rs.Rows)
|
||||
wm := make([][]int, rs.Rows)
|
||||
for r := 0; r < rs.Rows; r++ {
|
||||
lm[r] = make([]int, rs.Cols)
|
||||
wm[r] = make([]int, rs.Cols)
|
||||
for c := 0; c < rs.Cols; c++ {
|
||||
p := rs.Premium(r, c)
|
||||
lm[r][c] = p.LetterMult()
|
||||
wm[r][c] = p.WordMult()
|
||||
}
|
||||
}
|
||||
return genRuleset{
|
||||
Size: rs.Size(), Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize,
|
||||
Bingo: rs.Bingo, Values: rs.Values, LetterMult: lm, WordMult: wm,
|
||||
}
|
||||
}
|
||||
|
||||
func movesOf(ms []scrabble.Move) []genMove {
|
||||
out := make([]genMove, len(ms))
|
||||
for i, m := range ms {
|
||||
out[i] = genMove{Dir: int(m.Dir), Tiles: tilesOf(m.Tiles), Score: m.Score}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tilesOf(ps []scrabble.Placement) []genTile {
|
||||
out := make([]genTile, len(ps))
|
||||
for i, p := range ps {
|
||||
out[i] = genTile{Row: p.Row, Col: p.Col, Letter: int(p.Letter), Blank: p.Blank}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// emptyCase builds an empty-board case (Moves filled later by runCase).
|
||||
func emptyCase(name string, r genRack, mode scrabble.Mode, ignoreCross bool) genCase {
|
||||
return genCase{Name: name, Rack: r, Mode: int(mode), IgnoreCrossWords: ignoreCross}
|
||||
}
|
||||
|
||||
// englishRack builds a rack from lowercase a-z letters (index = letter-'a').
|
||||
func englishRack(letters string, blanks int) genRack {
|
||||
idx := make([]int, 0, len(letters))
|
||||
for _, ch := range letters {
|
||||
idx = append(idx, int(ch-'a'))
|
||||
}
|
||||
return genRack{Letters: idx, Blanks: blanks}
|
||||
}
|
||||
|
||||
// russianRack builds a rack from the Russian sample letters used above.
|
||||
func russianRack(letters string, blanks int) genRack {
|
||||
m := map[rune]int{'а': 0, 'д': 4, 'о': 15, 'р': 17, 'с': 18, 'я': 32}
|
||||
idx := make([]int, 0, len([]rune(letters)))
|
||||
for _, ch := range letters {
|
||||
i, ok := m[ch]
|
||||
if !ok {
|
||||
log.Fatalf("movegen: russianRack: no index for %q", string(ch))
|
||||
}
|
||||
idx = append(idx, i)
|
||||
}
|
||||
return genRack{Letters: idx, Blanks: blanks}
|
||||
}
|
||||
|
||||
// emitRulesets writes the per-variant static ruleset data (tile values, bag counts, blanks,
|
||||
// bingo, rack size) the offline engine mirrors in ui/src/lib/localgame/ruleset.ts, so a TS
|
||||
// parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules).
|
||||
func emitRulesets() {
|
||||
type rsFix struct {
|
||||
Size int `json:"size"`
|
||||
RackSize int `json:"rackSize"`
|
||||
Bingo int `json:"bingo"`
|
||||
Blanks int `json:"blanks"`
|
||||
Values []int `json:"values"`
|
||||
Counts []int `json:"counts"`
|
||||
Letters []string `json:"letters"`
|
||||
}
|
||||
out := map[string]rsFix{}
|
||||
for _, v := range []struct {
|
||||
name string
|
||||
rs *rules.Ruleset
|
||||
}{
|
||||
{"scrabble_en", rules.English()},
|
||||
{"scrabble_ru", rules.RussianScrabble()},
|
||||
{"erudit_ru", rules.Erudit()},
|
||||
} {
|
||||
letters := make([]string, v.rs.Size())
|
||||
for i := range letters {
|
||||
ch, err := v.rs.Alphabet.Character(byte(i))
|
||||
if err != nil {
|
||||
log.Fatalf("movegen: %s letter %d: %v", v.name, i, err)
|
||||
}
|
||||
letters[i] = strings.ToUpper(ch)
|
||||
}
|
||||
out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts, Letters: letters}
|
||||
}
|
||||
dir := filepath.Join("ui", "src", "lib", "localgame", "testdata")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
log.Fatalf("movegen: mkdir %s: %v", dir, err)
|
||||
}
|
||||
writeJSON(filepath.Join(dir, "rulesets.json"), out)
|
||||
log.Printf("movegen: wrote %s (3 variants)", filepath.Join(dir, "rulesets.json"))
|
||||
}
|
||||
|
||||
func writeJSON(path string, v any) {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("movegen: marshal %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||
log.Fatalf("movegen: write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -105,9 +105,10 @@ func (s *EmailService) allowSend(email string) bool {
|
||||
|
||||
// issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID,
|
||||
// email), replaces any prior pending confirmation, and mails the branded code in
|
||||
// locale; purpose selects the email wording and what verifying does. Only the SHA-256
|
||||
// hashes of the code and token are stored.
|
||||
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error {
|
||||
// locale; purpose selects the email wording and what verifying does. omitLink drops the
|
||||
// one-tap deeplink from the email (account deletion, or a login requested from an installed
|
||||
// PWA). Only the SHA-256 hashes of the code and token are stored.
|
||||
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string, omitLink bool) error {
|
||||
code, codeHash, err := generateCode()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -119,11 +120,12 @@ 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 {
|
||||
return err
|
||||
}
|
||||
// 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.
|
||||
// Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would
|
||||
// open in a separate browser whose minted session cannot reach the PWA), or for account
|
||||
// deletion (a prefetch or stray click must not delete an account — the delete code is entered
|
||||
// in the app only, and ConfirmByToken refuses a delete token).
|
||||
deeplink := s.confirmURL(token, locale)
|
||||
if purpose == purposeDelete {
|
||||
if purpose == purposeDelete || omitLink {
|
||||
deeplink = ""
|
||||
}
|
||||
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
|
||||
@@ -175,7 +177,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
|
||||
}
|
||||
return ErrEmailTaken
|
||||
}
|
||||
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
|
||||
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
|
||||
}
|
||||
|
||||
// ConfirmCode verifies code for accountID and email. On success it attaches a
|
||||
@@ -221,8 +223,11 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
|
||||
// the ordinary returning-user login. The code is mailed to the address, so only its
|
||||
// real owner can complete the login. On first contact browserTZ (the client's
|
||||
// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
|
||||
// language. It returns the target account id for the subsequent LoginWithCode.
|
||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) {
|
||||
// language. When pwa is set (the request came from an installed PWA) the login email omits the
|
||||
// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the
|
||||
// code is entered in the same window. It returns the target account id for the subsequent
|
||||
// LoginWithCode.
|
||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string, pwa bool) (uuid.UUID, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
@@ -234,7 +239,7 @@ func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, l
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil {
|
||||
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language, pwa); err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
return acc.ID, nil
|
||||
|
||||
@@ -92,7 +92,7 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
|
||||
if !s.allowSend(addr) {
|
||||
return ErrTooManyRequests
|
||||
}
|
||||
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
|
||||
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
|
||||
}
|
||||
|
||||
// ConfirmLink verifies code for (accountID, email) and reports the address's
|
||||
@@ -140,7 +140,7 @@ func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUI
|
||||
if !s.allowSend(addr) {
|
||||
return ErrTooManyRequests
|
||||
}
|
||||
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID))
|
||||
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID), false)
|
||||
}
|
||||
|
||||
// ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the
|
||||
@@ -199,7 +199,7 @@ func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUI
|
||||
if !s.allowSend(addr) {
|
||||
return ErrTooManyRequests
|
||||
}
|
||||
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID))
|
||||
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID), false)
|
||||
}
|
||||
|
||||
// VerifyDeleteCode verifies the account-deletion code against the account's own email and
|
||||
|
||||
@@ -15,7 +15,11 @@ import (
|
||||
// required plain-text body and doubles as the multipart/alternative fallback;
|
||||
// HTML, when non-empty, is the preferred body a capable client renders instead.
|
||||
type Message struct {
|
||||
// To is the recipient address, or several comma-separated (all get the one message).
|
||||
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
|
||||
Text string
|
||||
HTML string
|
||||
@@ -29,6 +33,18 @@ type Mailer interface {
|
||||
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
|
||||
// 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
|
||||
@@ -44,6 +60,11 @@ type SMTPConfig struct {
|
||||
// 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).
|
||||
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 (
|
||||
@@ -110,10 +131,16 @@ func (m SMTPMailer) Send(ctx context.Context, msg Message) error {
|
||||
return fmt.Errorf("account: build mail client: %w", err)
|
||||
}
|
||||
out := mail.NewMsg()
|
||||
if err := out.From(m.cfg.From); err != nil {
|
||||
return fmt.Errorf("account: set From %q: %w", m.cfg.From, err)
|
||||
from := m.cfg.From
|
||||
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)
|
||||
}
|
||||
out.Subject(msg.Subject)
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
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
|
||||
// the non-standard Selectel ports (1127 = SSL, 1126 = STARTTLS) that the 465 heuristic
|
||||
|
||||
@@ -21,9 +21,11 @@ import (
|
||||
const RetentionTTL = 2 * 365 * 24 * time.Hour
|
||||
|
||||
// Reasons recorded on a retained_identities row: what detached the credential from its
|
||||
// account. 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.
|
||||
// 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"
|
||||
|
||||
@@ -27,6 +27,11 @@ import (
|
||||
// without taking a dependency on the game package.
|
||||
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.
|
||||
const (
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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).
|
||||
func friendRank(status string) int {
|
||||
switch status {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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
|
||||
clock func() time.Time
|
||||
log *zap.Logger
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// New constructs a Notifier. from and to are the alert sender and recipient(s); log may be
|
||||
// nil. The watermark starts at "now", so only items arriving after start-up are reported. The
|
||||
// digest deliberately carries no admin-console link — an admin URL must never travel in an
|
||||
// email, where a mail provider could cache or index it.
|
||||
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &Notifier{
|
||||
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to,
|
||||
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, ", ")
|
||||
// No admin-console link in the body: an admin URL must never travel in an email (a mail
|
||||
// provider could cache or index it). The operator opens the console directly.
|
||||
text := summary + "."
|
||||
return account.Message{
|
||||
From: n.from,
|
||||
To: n.to,
|
||||
Subject: "Erudit — " + summary,
|
||||
Text: text,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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", 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)
|
||||
}
|
||||
// The digest must never carry an admin-console link — an admin URL in an email is a leak
|
||||
// (mail providers cache/index it).
|
||||
if strings.Contains(msg.Text, "/_gm") || strings.Contains(strings.ToLower(msg.Text), "admin console") {
|
||||
t.Errorf("digest body = %q, must not carry an admin-console link", msg.Text)
|
||||
}
|
||||
}
|
||||
@@ -193,3 +193,24 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
|
||||
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
|
||||
.replay-log li.cur { color: var(--ink); font-weight: 600; }
|
||||
|
||||
/* Banner colour override editor + live preview (banner_detail). The override
|
||||
fieldsets group the enable toggle with the native colour swatches; the preview
|
||||
renders a sample strip on both themes from those inputs (see the inline script). */
|
||||
.ovr { border: 1px solid var(--line); border-radius: 6px; padding: 0.3rem 0.8rem 0.7rem; margin: 0.2rem 0; }
|
||||
.ovr legend { padding: 0 0.3rem; font-size: 0.85rem; color: var(--ink); }
|
||||
.ovr legend label { flex-direction: row; align-items: center; gap: 0.4rem; color: var(--ink); }
|
||||
.ovr .note { margin: 0.2rem 0 0.4rem; }
|
||||
.ovr .swatches { display: flex; flex-wrap: wrap; gap: 1rem; }
|
||||
.ovr .swatches label { flex-direction: column; gap: 0.25rem; align-items: flex-start; }
|
||||
.ovr input[type=color] { width: 3rem; height: 1.8rem; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); cursor: pointer; }
|
||||
.ovr input[type=color]:disabled { opacity: 0.4; cursor: default; }
|
||||
.ovr .hex { font-size: 0.72rem; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
|
||||
.banner-preview { display: flex; flex-wrap: wrap; gap: 0.8rem; margin: 0.7rem 0 0.2rem; }
|
||||
.banner-preview .bp { flex: 1 1 18rem; }
|
||||
.banner-preview .bp-label { display: block; font-size: 0.75rem; color: var(--ink-dim); margin-bottom: 0.25rem; }
|
||||
.ad-frame { padding: 0.7rem; border-radius: 6px; border: 1px solid var(--line); }
|
||||
.ad-frame.light { background: #f4f6f9; }
|
||||
.ad-frame.dark { background: #0f1420; }
|
||||
.ad-sample { padding: 0.35rem 0.7rem; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ad-sample .ad-link { text-decoration: underline; }
|
||||
|
||||
@@ -11,10 +11,72 @@
|
||||
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
|
||||
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
|
||||
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
|
||||
<fieldset class="ovr">
|
||||
<legend><label><input type="checkbox" name="urgent"{{if .Urgent}} checked{{end}}> Urgent</label></legend>
|
||||
<p class="note">Shows to <em>everyone</em>, always — bypassing paid accounts, hint wallets and the no-banner role. While any urgent campaign is live it is the only thing the strip shows (other campaigns and the default are suppressed). For system alerts.</p>
|
||||
</fieldset>
|
||||
<fieldset class="ovr" data-ovr-group>
|
||||
<legend><label><input type="checkbox" name="override_all_on" data-ovr="all"{{if .OverrideAllOn}} checked{{end}}> Colour override — all themes</label></legend>
|
||||
<div class="swatches">
|
||||
<label>Background <input type="color" name="override_bg" value="{{.AllBg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
||||
<label>Text <input type="color" name="override_fg" value="{{.AllFg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
||||
<label>Link <input type="color" name="override_link" value="{{.AllLink}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="ovr" data-ovr-group>
|
||||
<legend><label><input type="checkbox" name="override_dark_on" data-ovr="dark"{{if .OverrideDarkOn}} checked{{end}}> Colour override — dark theme only</label></legend>
|
||||
<div class="swatches">
|
||||
<label>Background <input type="color" name="override_bg_dark" value="{{.DarkBg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
||||
<label>Text <input type="color" name="override_fg_dark" value="{{.DarkFg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
||||
<label>Link <input type="color" name="override_link_dark" value="{{.DarkLink}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
{{end}}
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
{{if not .IsDefault}}
|
||||
<div class="banner-preview">
|
||||
<div class="bp"><span class="bp-label">Light theme</span><div class="ad-frame light"><div class="ad-sample" id="prev-light"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
|
||||
<div class="bp"><span class="bp-label">Dark theme</span><div class="ad-frame dark"><div class="ad-sample" id="prev-dark"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
|
||||
</div>
|
||||
<p class="note">Live preview of the strip on both themes. An empty override falls back to the neutral theme colours; the top/bottom border is derived from the background.</p>
|
||||
<script>
|
||||
(function(){
|
||||
// Neutral fallbacks — mirror ui/src/app.css (--ad-bg / --text-muted / --accent).
|
||||
var TOK={light:{bg:'#e3e7ee',fg:'#6b7280',link:'#2f6df6'},dark:{bg:'#272f3c',fg:'#9aa3b2',link:'#5b8cff'}};
|
||||
function byName(n){return document.querySelector('[name="'+n+'"]');}
|
||||
var allOn=byName('override_all_on'), darkOn=byName('override_dark_on');
|
||||
if(!allOn||!darkOn){return;}
|
||||
var f={ab:byName('override_bg'),af:byName('override_fg'),al:byName('override_link'),db:byName('override_bg_dark'),df:byName('override_fg_dark'),dl:byName('override_link_dark')};
|
||||
var lightEl=document.getElementById('prev-light'), darkEl=document.getElementById('prev-dark');
|
||||
function hexToRgb(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
|
||||
function pad(x){x=Math.max(0,Math.min(255,Math.round(x))).toString(16);return x.length<2?'0'+x:x;}
|
||||
function rgbToHex(r){return '#'+pad(r[0])+pad(r[1])+pad(r[2]);}
|
||||
function mix(a,b,t){return [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];}
|
||||
function lum(r){return (0.2126*r[0]+0.7152*r[1]+0.0722*r[2])/255;}
|
||||
// Derived border: nudge the background 14% toward black on a light bg, toward white on a dark bg.
|
||||
function border(bg){var r=hexToRgb(bg);return rgbToHex(mix(r, lum(r)>0.5?[0,0,0]:[255,255,255], 0.14));}
|
||||
function paint(el,c){
|
||||
el.style.background=c.bg; el.style.color=c.fg;
|
||||
el.style.borderTop='1px solid '+border(c.bg); el.style.borderBottom='1px solid '+border(c.bg);
|
||||
var a=el.querySelector('.ad-link'); if(a){a.style.color=c.link;}
|
||||
}
|
||||
function resolve(){
|
||||
var light=allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.light;
|
||||
var dark=darkOn.checked?{bg:f.db.value,fg:f.df.value,link:f.dl.value}
|
||||
:(allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.dark);
|
||||
paint(lightEl,light); paint(darkEl,dark);
|
||||
document.querySelectorAll('.ovr .swatches label').forEach(function(lab){
|
||||
var inp=lab.querySelector('input[type=color]'), hx=lab.querySelector('.hex');
|
||||
if(inp&&hx){hx.textContent=inp.value;}
|
||||
});
|
||||
}
|
||||
function toggleGroup(chk){chk.closest('fieldset').querySelectorAll('[data-ovr-color]').forEach(function(inp){inp.disabled=!chk.checked;});}
|
||||
[allOn,darkOn].forEach(function(chk){chk.addEventListener('change',function(){toggleGroup(chk);resolve();});});
|
||||
Object.keys(f).forEach(function(k){f[k].addEventListener('input',resolve);});
|
||||
resolve();
|
||||
})();
|
||||
</script>
|
||||
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
|
||||
<button type="submit" class="danger">Delete campaign</button>
|
||||
</form>
|
||||
|
||||
@@ -498,6 +498,12 @@ type BannerCampaignRow struct {
|
||||
|
||||
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
|
||||
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
|
||||
//
|
||||
// The colour-override and urgent fields drive the non-default campaign's editor:
|
||||
// OverrideAllOn/OverrideDarkOn report whether each colour set is active, and the
|
||||
// six *Bg/*Fg/*Link values seed the native colour inputs — the stored override
|
||||
// when a set is on, otherwise the neutral theme token so the picker starts from a
|
||||
// sensible colour and the live preview shows the real fallback.
|
||||
type BannerDetailView struct {
|
||||
ID string
|
||||
Name string
|
||||
@@ -506,6 +512,15 @@ type BannerDetailView struct {
|
||||
Enabled bool
|
||||
StartsAt string
|
||||
EndsAt string
|
||||
Urgent bool
|
||||
OverrideAllOn bool
|
||||
AllBg string
|
||||
AllFg string
|
||||
AllLink string
|
||||
OverrideDarkOn bool
|
||||
DarkBg string
|
||||
DarkFg string
|
||||
DarkLink string
|
||||
Messages []BannerMessageRow
|
||||
}
|
||||
|
||||
|
||||
+75
-11
@@ -30,6 +30,15 @@ var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modifi
|
||||
// window or message body).
|
||||
var ErrValidation = errors.New("ads: validation")
|
||||
|
||||
// ColorSet is an optional per-campaign colour override for the banner strip:
|
||||
// background, foreground (text) and link, each a "#rrggbb" hex string. The three
|
||||
// are set together or the whole set is absent (a nil *ColorSet).
|
||||
type ColorSet struct {
|
||||
Bg string
|
||||
Fg string
|
||||
Link string
|
||||
}
|
||||
|
||||
// Campaign is one advertising placement order with its messages.
|
||||
type Campaign struct {
|
||||
ID uuid.UUID // uuid.Nil on create
|
||||
@@ -40,6 +49,16 @@ type Campaign struct {
|
||||
StartsAt *time.Time // nil = open-ended start; always nil for the default
|
||||
EndsAt *time.Time // nil = open-ended end; always nil for the default
|
||||
Messages []Message
|
||||
// OverrideAll paints the strip on every theme; OverrideDark, when set, further
|
||||
// overrides the dark theme (the client resolves dark ← dark ?? all ?? token,
|
||||
// light ← all ?? token). Both are nil for the default campaign and for a
|
||||
// campaign that keeps the neutral theme tokens. Non-default only.
|
||||
OverrideAll *ColorSet
|
||||
OverrideDark *ColorSet
|
||||
// Urgent forces the banner on every viewer (bypassing eligibility) and, while
|
||||
// any urgent campaign is active, suppresses every non-urgent campaign and the
|
||||
// default remainder. Non-default only; always false for the default campaign.
|
||||
Urgent bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -70,10 +89,15 @@ type Timings struct {
|
||||
|
||||
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
|
||||
// its GCD-reduced show weight and its messages, already resolved to the viewer's
|
||||
// language and in display (round-robin) order.
|
||||
// language and in display (round-robin) order, plus the optional colour overrides
|
||||
// the client applies to the strip (nil = the neutral theme tokens). Urgency is not
|
||||
// carried here: it is resolved server-side into the set's membership and the
|
||||
// eligibility bypass, so the client only ever renders what it is sent.
|
||||
type ActiveCampaign struct {
|
||||
Weight int
|
||||
Messages []string
|
||||
OverrideAll *ColorSet
|
||||
OverrideDark *ColorSet
|
||||
}
|
||||
|
||||
// Eligible reports whether an account should be shown the advertising banner: a
|
||||
@@ -85,14 +109,20 @@ func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
|
||||
}
|
||||
|
||||
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
|
||||
// at time now, in language lang. Campaigns outside their validity window, and
|
||||
// campaigns with no messages, are dropped. The default campaign's effective
|
||||
// weight is the remainder up to 100% — max(0, 100 - sum of active timed
|
||||
// weights) — so it fills unsold inventory and is dropped entirely when timed
|
||||
// campaigns already reach 100%. Weights are then reduced by their GCD so the
|
||||
// fair rotation cycle stays short. The input is expected to be the enabled
|
||||
// campaigns (ActiveCampaigns); disabled ones must already be excluded.
|
||||
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign {
|
||||
// at time now, in language lang, and reports whether the feed is an urgent one.
|
||||
// Campaigns outside their validity window, and campaigns with no messages, are
|
||||
// dropped.
|
||||
//
|
||||
// While any active campaign is urgent, the feed is the urgent campaigns alone —
|
||||
// every non-urgent timed campaign and the default remainder are suppressed for
|
||||
// the duration (and the caller shows the feed to every viewer, bypassing
|
||||
// eligibility). Otherwise the default campaign's effective weight is the
|
||||
// remainder up to 100% — max(0, 100 - sum of active timed weights) — so it fills
|
||||
// unsold inventory and is dropped entirely when timed campaigns already reach
|
||||
// 100%. Weights are then reduced by their GCD so the fair rotation cycle stays
|
||||
// short. The input is expected to be the enabled campaigns (ActiveCampaigns);
|
||||
// disabled ones must already be excluded.
|
||||
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]ActiveCampaign, bool) {
|
||||
var timed []Campaign
|
||||
var def *Campaign
|
||||
for i := range campaigns {
|
||||
@@ -108,20 +138,54 @@ func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []Active
|
||||
timed = append(timed, c)
|
||||
}
|
||||
}
|
||||
if urgent := filterUrgent(timed); len(urgent) > 0 {
|
||||
out := make([]ActiveCampaign, 0, len(urgent))
|
||||
for _, c := range urgent {
|
||||
out = append(out, activeFrom(c, lang))
|
||||
}
|
||||
reduceByGCD(out)
|
||||
return out, true
|
||||
}
|
||||
sumTimed := 0
|
||||
for _, c := range timed {
|
||||
sumTimed += c.Weight
|
||||
}
|
||||
out := make([]ActiveCampaign, 0, len(timed)+1)
|
||||
for _, c := range timed {
|
||||
out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)})
|
||||
out = append(out, activeFrom(c, lang))
|
||||
}
|
||||
if def != nil {
|
||||
if dw := 100 - sumTimed; dw > 0 {
|
||||
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
|
||||
a := activeFrom(*def, lang)
|
||||
a.Weight = dw // the default's stored weight is nominal; it fills the remainder
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
reduceByGCD(out)
|
||||
return out, false
|
||||
}
|
||||
|
||||
// activeFrom projects a campaign to its rotation-feed entry: its show weight, its
|
||||
// language-resolved messages and its colour overrides (carried through by
|
||||
// reference, they are read-only).
|
||||
func activeFrom(c Campaign, lang string) ActiveCampaign {
|
||||
return ActiveCampaign{
|
||||
Weight: c.Weight,
|
||||
Messages: resolveBodies(c.Messages, lang),
|
||||
OverrideAll: c.OverrideAll,
|
||||
OverrideDark: c.OverrideDark,
|
||||
}
|
||||
}
|
||||
|
||||
// filterUrgent returns the urgent campaigns among cs, preserving order. It is the
|
||||
// preempt selector: a non-empty result makes the whole feed urgent.
|
||||
func filterUrgent(cs []Campaign) []Campaign {
|
||||
var out []Campaign
|
||||
for _, c := range cs {
|
||||
if c.Urgent {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -46,12 +46,19 @@ func TestComputeActiveSet(t *testing.T) {
|
||||
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
|
||||
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
|
||||
}
|
||||
urgent := func(name string, weight int, msgs []Message) Campaign {
|
||||
c := timed(name, weight, nil, nil, msgs)
|
||||
c.Urgent = true
|
||||
return c
|
||||
}
|
||||
red := &ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
campaigns []Campaign
|
||||
lang string
|
||||
want []ActiveCampaign
|
||||
wantUrgent bool
|
||||
}{
|
||||
{
|
||||
name: "default only reduces to weight 1",
|
||||
@@ -152,22 +159,71 @@ func TestComputeActiveSet(t *testing.T) {
|
||||
lang: "en",
|
||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
|
||||
},
|
||||
{
|
||||
name: "urgent preempts default and normal timed",
|
||||
campaigns: []Campaign{
|
||||
def(msg("house")),
|
||||
timed("promo", 40, nil, nil, msg("promo")),
|
||||
urgent("alert", 50, msg("alert")),
|
||||
},
|
||||
lang: "en",
|
||||
// only the urgent campaign survives; a lone weight reduces to 1.
|
||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"alert-en"}}},
|
||||
wantUrgent: true,
|
||||
},
|
||||
{
|
||||
name: "multiple urgent share the feed by gcd",
|
||||
campaigns: []Campaign{
|
||||
def(msg("house")),
|
||||
urgent("a", 60, msg("a")),
|
||||
urgent("b", 40, msg("b")),
|
||||
},
|
||||
lang: "en",
|
||||
// default and any non-urgent dropped; gcd(60,40)=20 -> 3 and 2.
|
||||
want: []ActiveCampaign{
|
||||
{Weight: 3, Messages: []string{"a-en"}},
|
||||
{Weight: 2, Messages: []string{"b-en"}},
|
||||
},
|
||||
wantUrgent: true,
|
||||
},
|
||||
{
|
||||
name: "out-of-window urgent does not preempt",
|
||||
campaigns: []Campaign{
|
||||
def(msg("house")),
|
||||
func() Campaign { c := urgent("future", 50, msg("future")); c.StartsAt = &future; return c }(),
|
||||
},
|
||||
lang: "en",
|
||||
// the urgent campaign is not yet live, so the normal default feed stands.
|
||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
|
||||
},
|
||||
{
|
||||
name: "colour overrides ride the active campaign",
|
||||
campaigns: []Campaign{
|
||||
def(msg("house")),
|
||||
func() Campaign { c := timed("promo", 100, nil, nil, msg("promo")); c.OverrideAll = red; return c }(),
|
||||
},
|
||||
lang: "en",
|
||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}, OverrideAll: red}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := computeActiveSet(tt.campaigns, now, tt.lang)
|
||||
got, gotUrgent := computeActiveSet(tt.campaigns, now, tt.lang)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
|
||||
}
|
||||
if gotUrgent != tt.wantUrgent {
|
||||
t.Errorf("computeActiveSet() urgent = %v, want %v", gotUrgent, tt.wantUrgent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeActiveSetEmpty(t *testing.T) {
|
||||
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
|
||||
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
|
||||
t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
|
||||
if got, urgent := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 || urgent {
|
||||
t.Errorf("computeActiveSet(nil) = %#v urgent=%v, want empty non-urgent", got, urgent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package ads
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -40,17 +41,20 @@ func NewService(store *Store) *Service { return &Service{store: store} }
|
||||
// ActiveSet returns the resolved rotation feed for a viewer in language lang
|
||||
// (en/ru) together with the global display timings: the currently-active
|
||||
// campaigns, each with its GCD-reduced show weight and its messages resolved to
|
||||
// lang, ready for the client's weighted round-robin.
|
||||
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
|
||||
// lang, ready for the client's weighted round-robin. The bool result reports
|
||||
// whether the feed is urgent — an urgent feed is shown to every viewer
|
||||
// regardless of eligibility (the caller skips the eligibility gate for it).
|
||||
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, bool, error) {
|
||||
campaigns, err := s.store.ActiveCampaigns(ctx)
|
||||
if err != nil {
|
||||
return nil, Timings{}, err
|
||||
return nil, Timings{}, false, err
|
||||
}
|
||||
timings, err := s.store.Settings(ctx)
|
||||
if err != nil {
|
||||
return nil, Timings{}, err
|
||||
return nil, Timings{}, false, err
|
||||
}
|
||||
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
|
||||
set, urgent := computeActiveSet(campaigns, time.Now().UTC(), lang)
|
||||
return set, timings, urgent, nil
|
||||
}
|
||||
|
||||
// ListCampaigns returns every campaign with its messages, for the admin console.
|
||||
@@ -76,8 +80,13 @@ func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, er
|
||||
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return s.store.CreateCampaign(ctx, Campaign{
|
||||
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
|
||||
OverrideAll: all, OverrideDark: dark, Urgent: c.Urgent,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,6 +108,7 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
|
||||
upd.Enabled = true
|
||||
upd.StartsAt = nil
|
||||
upd.EndsAt = nil
|
||||
// The default (house) campaign stays plain: no colour overrides, never urgent.
|
||||
} else {
|
||||
if err := validWeight(c.Weight); err != nil {
|
||||
return err
|
||||
@@ -106,10 +116,17 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
|
||||
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upd.Weight = c.Weight
|
||||
upd.Enabled = c.Enabled
|
||||
upd.StartsAt = c.StartsAt
|
||||
upd.EndsAt = c.EndsAt
|
||||
upd.OverrideAll = all
|
||||
upd.OverrideDark = dark
|
||||
upd.Urgent = c.Urgent
|
||||
}
|
||||
return s.store.UpdateCampaign(ctx, upd)
|
||||
}
|
||||
@@ -254,6 +271,46 @@ func validWindow(starts, ends *time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// hexColor matches a "#rrggbb" colour — the format the console's native colour
|
||||
// input emits and the wire carries.
|
||||
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
|
||||
// validOverrides validates the two optional colour sets together and returns
|
||||
// their normalised copies (nil when a set is absent), so a caller can store them
|
||||
// directly.
|
||||
func validOverrides(all, dark *ColorSet) (*ColorSet, *ColorSet, error) {
|
||||
va, err := validColorSet(all)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
vd, err := validColorSet(dark)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return va, vd, nil
|
||||
}
|
||||
|
||||
// validColorSet validates one optional colour override: a nil set passes (no
|
||||
// override); otherwise all three colours must be present and "#rrggbb". It
|
||||
// returns a trimmed, lower-cased copy.
|
||||
func validColorSet(cs *ColorSet) (*ColorSet, error) {
|
||||
if cs == nil {
|
||||
return nil, nil
|
||||
}
|
||||
bg := strings.TrimSpace(cs.Bg)
|
||||
fg := strings.TrimSpace(cs.Fg)
|
||||
link := strings.TrimSpace(cs.Link)
|
||||
if bg == "" || fg == "" || link == "" {
|
||||
return nil, fmt.Errorf("%w: a colour override needs all of background, text and link", ErrValidation)
|
||||
}
|
||||
for _, h := range []string{bg, fg, link} {
|
||||
if !hexColor.MatchString(h) {
|
||||
return nil, fmt.Errorf("%w: colours must be #rrggbb hex", ErrValidation)
|
||||
}
|
||||
}
|
||||
return &ColorSet{Bg: strings.ToLower(bg), Fg: strings.ToLower(fg), Link: strings.ToLower(link)}, nil
|
||||
}
|
||||
|
||||
// validBodies trims and bounds both mandatory language bodies of a message.
|
||||
func validBodies(bodyEn, bodyRu string) (string, string, error) {
|
||||
en := strings.TrimSpace(bodyEn)
|
||||
|
||||
@@ -90,15 +90,23 @@ func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
|
||||
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
abg, afg, alink := colorCols(c.OverrideAll)
|
||||
dbg, dfg, dlink := colorCols(c.OverrideDark)
|
||||
stmt := table.AdCampaigns.INSERT(
|
||||
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
|
||||
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
|
||||
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
|
||||
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
|
||||
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
|
||||
table.AdCampaigns.Urgent,
|
||||
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
|
||||
).VALUES(
|
||||
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
|
||||
postgres.Bool(false), postgres.Bool(c.Enabled),
|
||||
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
||||
abg, afg, alink,
|
||||
dbg, dfg, dlink,
|
||||
postgres.Bool(c.Urgent),
|
||||
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
@@ -111,12 +119,20 @@ func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, erro
|
||||
// window. The default flag is never touched here. Returns ErrNotFound when no
|
||||
// campaign matches.
|
||||
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
|
||||
abg, afg, alink := colorCols(c.OverrideAll)
|
||||
dbg, dfg, dlink := colorCols(c.OverrideDark)
|
||||
stmt := table.AdCampaigns.UPDATE(
|
||||
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
|
||||
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt,
|
||||
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
|
||||
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
|
||||
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
|
||||
table.AdCampaigns.Urgent, table.AdCampaigns.UpdatedAt,
|
||||
).SET(
|
||||
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
|
||||
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()),
|
||||
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
||||
abg, afg, alink,
|
||||
dbg, dfg, dlink,
|
||||
postgres.Bool(c.Urgent), postgres.TimestampzT(time.Now().UTC()),
|
||||
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
|
||||
return execOne(ctx, s.db, stmt, "update campaign")
|
||||
}
|
||||
@@ -264,11 +280,33 @@ func modelToCampaign(r model.AdCampaigns) Campaign {
|
||||
Enabled: r.Enabled,
|
||||
StartsAt: r.StartsAt,
|
||||
EndsAt: r.EndsAt,
|
||||
OverrideAll: colorSetFrom(r.OverrideBg, r.OverrideFg, r.OverrideLink),
|
||||
OverrideDark: colorSetFrom(r.OverrideBgDark, r.OverrideFgDark, r.OverrideLinkDark),
|
||||
Urgent: r.Urgent,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// colorSetFrom rebuilds an optional ColorSet from three nullable colour columns.
|
||||
// The all-or-nothing CHECK keeps the trio consistent, so a nil in any one means
|
||||
// the set is absent.
|
||||
func colorSetFrom(bg, fg, link *string) *ColorSet {
|
||||
if bg == nil || fg == nil || link == nil {
|
||||
return nil
|
||||
}
|
||||
return &ColorSet{Bg: *bg, Fg: *fg, Link: *link}
|
||||
}
|
||||
|
||||
// colorCols renders a *ColorSet as its three column value-expressions in
|
||||
// bg, fg, link order — NULL for each when the set is absent.
|
||||
func colorCols(cs *ColorSet) (bg, fg, link postgres.Expression) {
|
||||
if cs == nil {
|
||||
return postgres.NULL, postgres.NULL, postgres.NULL
|
||||
}
|
||||
return postgres.String(cs.Bg), postgres.String(cs.Fg), postgres.String(cs.Link)
|
||||
}
|
||||
|
||||
func modelToMessage(r model.AdMessages) Message {
|
||||
return Message{
|
||||
ID: r.MessageID,
|
||||
|
||||
@@ -149,6 +149,8 @@ func Load() (Config, error) {
|
||||
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
|
||||
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
|
||||
TLS: os.Getenv("BACKEND_SMTP_TLS"),
|
||||
AdminFrom: os.Getenv("BACKEND_SMTP_ADMIN_FROM"),
|
||||
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
||||
}
|
||||
|
||||
c := Config{
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// The offline engine (ui/src/lib/localgame) reproduces the end-of-game rack settlement and the
|
||||
// winner rule so a local game finishes with the same scores as the server. These golden fixtures
|
||||
// pin the ported pure functions (applyEndAdjustment / winner / rackValue) to the real Go engine.
|
||||
// Being in-package, this emitter constructs Game values directly and drives the unexported
|
||||
// end-game math on chosen positions.
|
||||
|
||||
type endCaseIn struct {
|
||||
Name string `json:"name"`
|
||||
Variant string `json:"variant"`
|
||||
Reason string `json:"reason"`
|
||||
Hands [][]int `json:"hands"`
|
||||
Scores []int `json:"scores"`
|
||||
Resigned []bool `json:"resigned"`
|
||||
ToMove int `json:"toMove"`
|
||||
}
|
||||
|
||||
type endCaseOut struct {
|
||||
endCaseIn
|
||||
ScoresAfter []int `json:"scoresAfter"`
|
||||
Winner int `json:"winner"`
|
||||
}
|
||||
|
||||
func rulesetFor(variant string) *rules.Ruleset {
|
||||
switch variant {
|
||||
case "scrabble_ru":
|
||||
return rules.RussianScrabble()
|
||||
case "erudit_ru":
|
||||
return rules.Erudit()
|
||||
default:
|
||||
return rules.English()
|
||||
}
|
||||
}
|
||||
|
||||
func reasonFor(s string) EndReason {
|
||||
switch s {
|
||||
case "out_of_tiles":
|
||||
return EndOutOfTiles
|
||||
case "scoreless":
|
||||
return EndScoreless
|
||||
case "resign":
|
||||
return EndResign
|
||||
case "aborted":
|
||||
return EndAborted
|
||||
}
|
||||
return EndNotOver
|
||||
}
|
||||
|
||||
func handsBytes(hands [][]int) [][]byte {
|
||||
out := make([][]byte, len(hands))
|
||||
for i, h := range hands {
|
||||
b := make([]byte, len(h))
|
||||
for j, x := range h {
|
||||
b[j] = byte(x)
|
||||
}
|
||||
out[i] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestEmitEndgameFixtures regenerates ui/src/lib/localgame/testdata/endgame.json. Gated by
|
||||
// EMIT_ENGINE_FIXTURES. Regenerate with:
|
||||
//
|
||||
// EMIT_ENGINE_FIXTURES=1 go test ./backend/internal/engine -run TestEmitEndgameFixtures
|
||||
func TestEmitEndgameFixtures(t *testing.T) {
|
||||
if os.Getenv("EMIT_ENGINE_FIXTURES") == "" {
|
||||
t.Skip("set EMIT_ENGINE_FIXTURES=1 to regenerate ui/src/lib/localgame/testdata/endgame.json")
|
||||
}
|
||||
|
||||
cases := []endCaseIn{
|
||||
{"out-basic", "scrabble_en", "out_of_tiles", [][]int{{}, {0, 1, 2}}, []int{50, 40}, []bool{false, false}, 0},
|
||||
{"out-blank", "scrabble_en", "out_of_tiles", [][]int{{}, {255, 0}}, []int{30, 30}, []bool{false, false}, 0},
|
||||
{"out-erudit-yo", "erudit_ru", "out_of_tiles", [][]int{{}, {6, 32}}, []int{10, 10}, []bool{false, false}, 0},
|
||||
{"out-tie", "scrabble_en", "out_of_tiles", [][]int{{}, {}}, []int{30, 30}, []bool{false, false}, 0},
|
||||
{"out-3p", "scrabble_ru", "out_of_tiles", [][]int{{}, {0, 1}, {2}}, []int{10, 10, 10}, []bool{false, false, false}, 0},
|
||||
{"scoreless", "scrabble_en", "scoreless", [][]int{{0, 1}, {2, 3}}, []int{20, 20}, []bool{false, false}, 0},
|
||||
{"resign-2p", "scrabble_en", "resign", [][]int{{}, {0}}, []int{100, 10}, []bool{true, false}, 1},
|
||||
{"resign-3p", "scrabble_en", "resign", [][]int{{}, {}, {}}, []int{50, 60, 40}, []bool{false, true, false}, 0},
|
||||
{"aborted", "scrabble_en", "aborted", [][]int{{0}, {1}}, []int{40, 30}, []bool{false, false}, 0},
|
||||
}
|
||||
|
||||
out := make([]endCaseOut, 0, len(cases))
|
||||
for _, c := range cases {
|
||||
rs := rulesetFor(c.Variant)
|
||||
scores := append([]int(nil), c.Scores...)
|
||||
g := &Game{
|
||||
rules: rs,
|
||||
hands: handsBytes(c.Hands),
|
||||
scores: scores,
|
||||
resigned: c.Resigned,
|
||||
toMove: c.ToMove,
|
||||
}
|
||||
reason := reasonFor(c.Reason)
|
||||
g.over = true
|
||||
g.reason = reason
|
||||
g.applyEndAdjustment(reason)
|
||||
out = append(out, endCaseOut{endCaseIn: c, ScoresAfter: g.scores, Winner: g.winner()})
|
||||
}
|
||||
|
||||
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "localgame", "testdata")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
data, err := json.MarshalIndent(map[string]any{"cases": out}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "endgame.json")
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
t.Logf("wrote %s (%d cases)", path, len(out))
|
||||
}
|
||||
@@ -195,6 +195,11 @@ func (svc *Service) CountUnread(ctx context.Context) (int, error) {
|
||||
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.
|
||||
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
|
||||
return svc.store.Attachment(ctx, id)
|
||||
|
||||
@@ -386,3 +386,15 @@ func (s *Store) CountUnread(ctx context.Context) (int, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -69,6 +69,29 @@ func TestHintsRemaining(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHintUnlockLeftSeconds(t *testing.T) {
|
||||
now := time.Now()
|
||||
// A gated vs_ai game on the caller's turn, the robot having moved 10 min ago (turn started then).
|
||||
gated := Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}
|
||||
cases := []struct {
|
||||
name string
|
||||
g Game
|
||||
seat int
|
||||
want int
|
||||
}{
|
||||
{"non-vs_ai is open", Game{VsAI: false, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}, 0, 0},
|
||||
{"not the caller's turn is open", gated, 1, 0},
|
||||
{"human first move (no robot move) is open", Game{VsAI: true, ToMove: 0, MoveCount: 0, TurnStartedAt: now}, 0, 0},
|
||||
{"robot moved 10 min ago leaves 20 min", gated, 0, 20 * 60},
|
||||
{"robot moved past the window is open", Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-40 * time.Minute)}, 0, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := hintUnlockLeftSeconds(c.g, c.seat, now); got != c.want {
|
||||
t.Errorf("%s: hintUnlockLeftSeconds = %d, want %d", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedTimeout(t *testing.T) {
|
||||
if !allowedTimeout(24 * time.Hour) {
|
||||
t.Error("24h must be allowed")
|
||||
|
||||
@@ -1008,6 +1008,12 @@ func (svc *Service) CountComplaints(ctx context.Context, status string) (int, er
|
||||
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 /
|
||||
// accept_add / accept_remove) and an optional note. An accepted complaint then
|
||||
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
|
||||
@@ -1052,10 +1058,31 @@ func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Varia
|
||||
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
|
||||
}
|
||||
|
||||
// Hint reveals the top-scoring legal play for the requesting player on their
|
||||
// turn, spending one hint from their per-game allowance and then their profile
|
||||
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
||||
// appropriate.
|
||||
// hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move)
|
||||
// before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min).
|
||||
const hintIdleWindow = 30 * time.Minute
|
||||
|
||||
// hintUnlockLeftSeconds is the seconds until g's vs_ai idle hint unlocks for seat, measured from now:
|
||||
// the robot's last move (the current turn's start, on the human's turn) plus the window, floored at
|
||||
// 0 and ceiled to whole seconds. It is 0 for a non-vs_ai game, when it is not seat's turn, or on the
|
||||
// human's first move (MoveCount 0, no robot move yet) — the gate is an anti-frustration aid, not a
|
||||
// first-move tax. The client anchors a monotonic countdown to it, so a client clock cannot skew it.
|
||||
func hintUnlockLeftSeconds(g Game, seat int, now time.Time) int {
|
||||
if !g.VsAI || g.ToMove != seat || g.MoveCount < 1 {
|
||||
return 0
|
||||
}
|
||||
left := g.TurnStartedAt.Add(hintIdleWindow).Sub(now)
|
||||
if left <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int((left + time.Second - 1) / time.Second) // ceil to whole seconds (no math import)
|
||||
}
|
||||
|
||||
// Hint reveals the top-scoring legal play for the requesting player on their turn. For a human game
|
||||
// it spends one hint from the per-game allowance then the profile wallet (ErrHintsDisabled /
|
||||
// ErrNoHintsLeft / ErrNoHintAvailable). For a vs_ai game the hint is unlimited and wallet-free but
|
||||
// idle-gated from the server clock (ErrHintLocked until the window elapses) and counts toward no
|
||||
// hint statistic.
|
||||
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
|
||||
pre, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
@@ -1074,6 +1101,26 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
if !pre.HintsAllowed {
|
||||
return HintResult{}, ErrHintsDisabled
|
||||
}
|
||||
if pre.VsAI {
|
||||
// vs_ai: unlimited and wallet-free, but idle-gated from the server clock — and counted toward
|
||||
// no hint statistic. Enforce the gate (the client normally pre-gates from the view's
|
||||
// HintUnlockLeftSeconds; this is the authoritative backstop), then serve the top move without
|
||||
// touching the allowance, the wallet or hints_used.
|
||||
if hintUnlockLeftSeconds(pre, seat, svc.clock()) > 0 {
|
||||
return HintResult{}, ErrHintLocked
|
||||
}
|
||||
unlock := svc.locks.lock(gameID)
|
||||
defer unlock()
|
||||
g, err := svc.liveGame(ctx, pre)
|
||||
if err != nil {
|
||||
return HintResult{}, err
|
||||
}
|
||||
move, ok := g.HintView()
|
||||
if !ok {
|
||||
return HintResult{}, ErrNoHintAvailable
|
||||
}
|
||||
return HintResult{Move: move}, nil
|
||||
}
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return HintResult{}, err
|
||||
@@ -1202,6 +1249,8 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
BagLen: g.BagLen(),
|
||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
||||
WalletBalance: acc.HintBalance,
|
||||
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
|
||||
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1040,6 +1040,19 @@ func (s *Store) CountComplaints(ctx context.Context, status string) (int, error)
|
||||
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
|
||||
// filters them against the per-move deadline and the player's away window.
|
||||
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
|
||||
|
||||
@@ -65,6 +65,10 @@ var (
|
||||
ErrNoHintsLeft = errors.New("game: no hints remaining")
|
||||
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
|
||||
ErrNoHintAvailable = errors.New("game: no legal move to suggest")
|
||||
// ErrHintLocked is returned when a vs_ai game's idle hint is still gated (the player has not yet
|
||||
// been stuck the idle window since the robot's last move). The client normally pre-gates from
|
||||
// StateView.HintUnlockLeftSeconds, so this is the authoritative server-clock backstop.
|
||||
ErrHintLocked = errors.New("game: hint is not available yet")
|
||||
// ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames
|
||||
// simultaneous quick games and tries to create another game (quick or by invitation).
|
||||
ErrGameLimitReached = errors.New("game: simultaneous game limit reached")
|
||||
@@ -240,6 +244,11 @@ type StateView struct {
|
||||
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
|
||||
// it in with the per-game allowance), so the client keeps the wallet live across games.
|
||||
WalletBalance int
|
||||
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left
|
||||
// until the idle hint unlocks (the robot's last move plus the idle window, from the server clock);
|
||||
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
|
||||
// unlimited and wallet-free; this idle gate replaces the allowance/wallet for it.
|
||||
HintUnlockLeftSeconds int
|
||||
}
|
||||
|
||||
// HistoryMove is one decoded journal row, independent of any dictionary.
|
||||
|
||||
@@ -194,6 +194,7 @@ type profileBanner struct {
|
||||
Campaigns []struct {
|
||||
Weight int `json:"weight"`
|
||||
Messages []string `json:"messages"`
|
||||
OverrideBg string `json:"override_bg"`
|
||||
} `json:"campaigns"`
|
||||
Timings struct {
|
||||
HoldMs int `json:"hold_ms"`
|
||||
@@ -274,3 +275,142 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
|
||||
t.Fatalf("profile update dropped the banner block: %v", p.Banner)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBannerConsoleColorsAndUrgent drives the colour-override + urgent editor over
|
||||
// HTTP against real Postgres: an incomplete or malformed colour set is refused, a
|
||||
// full two-set override + urgent round-trips through the store (exercising the new
|
||||
// columns and CHECK constraints), clearing the sets removes the override, and the
|
||||
// default campaign stays plain (colours + urgent are ignored for it).
|
||||
func TestBannerConsoleColorsAndUrgent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, adsSvc := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
base := "http://admin.test/_gm"
|
||||
const origin = "http://admin.test"
|
||||
|
||||
name := "Brand-" + uuid.NewString()[:8]
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
|
||||
t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created"))
|
||||
}
|
||||
id := findCampaign(t, adsSvc, name)
|
||||
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, id) })
|
||||
detail := base + "/banners/" + id.String()
|
||||
|
||||
// A partial colour set (a missing colour) is refused by validation.
|
||||
partial := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=%23aa0000&override_fg=&override_link=%23ffdd00"
|
||||
if code, b := consoleDo(h, http.MethodPost, detail, partial, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
|
||||
t.Fatalf("partial colour = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
|
||||
}
|
||||
// A malformed hex is refused.
|
||||
badHex := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=red&override_fg=%23ffffff&override_link=%23ffdd00"
|
||||
if code, b := consoleDo(h, http.MethodPost, detail, badHex, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
|
||||
t.Fatalf("bad hex = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
|
||||
}
|
||||
// Both colour sets + urgent persist and round-trip through the store.
|
||||
full := "name=" + name + "&weight=20&enabled=on&urgent=on" +
|
||||
"&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00" +
|
||||
"&override_dark_on=on&override_bg_dark=%23330000&override_fg_dark=%23eeeeee&override_link_dark=%23ffcc00"
|
||||
if code, b := consoleDo(h, http.MethodPost, detail, full, origin); code != http.StatusOK || !strings.Contains(b, "Saved") {
|
||||
t.Fatalf("full save = %d, has Saved=%v", code, strings.Contains(b, "Saved"))
|
||||
}
|
||||
cm, err := adsSvc.Campaign(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("read campaign: %v", err)
|
||||
}
|
||||
if cm.OverrideAll == nil || cm.OverrideAll.Bg != "#aa0000" || cm.OverrideAll.Fg != "#ffffff" || cm.OverrideAll.Link != "#ffdd00" {
|
||||
t.Fatalf("override all = %+v", cm.OverrideAll)
|
||||
}
|
||||
if cm.OverrideDark == nil || cm.OverrideDark.Bg != "#330000" || cm.OverrideDark.Fg != "#eeeeee" || cm.OverrideDark.Link != "#ffcc00" {
|
||||
t.Fatalf("override dark = %+v", cm.OverrideDark)
|
||||
}
|
||||
if !cm.Urgent {
|
||||
t.Fatal("urgent not persisted")
|
||||
}
|
||||
// Clearing the sets (both checkboxes off, urgent off) removes the override.
|
||||
if code, _ := consoleDo(h, http.MethodPost, detail, "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK {
|
||||
t.Fatalf("clear = %d", code)
|
||||
}
|
||||
if cm, _ := adsSvc.Campaign(ctx, id); cm.OverrideAll != nil || cm.OverrideDark != nil || cm.Urgent {
|
||||
t.Fatalf("override not cleared: all=%v dark=%v urgent=%v", cm.OverrideAll, cm.OverrideDark, cm.Urgent)
|
||||
}
|
||||
|
||||
// The default campaign stays plain: submitted colours + urgent are ignored for it.
|
||||
def := defaultCampaign(t, adsSvc)
|
||||
defForm := "name=Default+%28house%29&urgent=on&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00"
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String(), defForm, origin); code != http.StatusOK {
|
||||
t.Fatalf("update default = %d", code)
|
||||
}
|
||||
if again := defaultCampaign(t, adsSvc); again.OverrideAll != nil || again.OverrideDark != nil || again.Urgent {
|
||||
t.Fatalf("default not plain: all=%v dark=%v urgent=%v", again.OverrideAll, again.OverrideDark, again.Urgent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBannerUrgentBypassesEligibility checks the urgent flag at the profile level:
|
||||
// an urgent campaign preempts the feed (only it shows, with its colours) and reaches
|
||||
// every viewer — including the no_banner role and a non-empty hint wallet that
|
||||
// otherwise suppress the banner.
|
||||
func TestBannerUrgentBypassesEligibility(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, adsSvc := bannerServer(t)
|
||||
accounts := account.NewStore(testDB)
|
||||
id := provisionAccount(t)
|
||||
|
||||
urgentID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{
|
||||
Name: "Alert-" + uuid.NewString()[:8], Weight: 10, Enabled: true, Urgent: true,
|
||||
OverrideAll: &ads.ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create urgent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, urgentID) })
|
||||
if _, err := adsSvc.AddMessage(ctx, urgentID, "System maintenance tonight", "Ночью тех.работы"); err != nil {
|
||||
t.Fatalf("add urgent message: %v", err)
|
||||
}
|
||||
|
||||
get := func() profileBanner {
|
||||
t.Helper()
|
||||
rec := userGet(t, srv, "/api/v1/user/profile", id)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("profile = %d", rec.Code)
|
||||
}
|
||||
var p profileBanner
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
assertUrgentOnly := func(what string, p profileBanner) {
|
||||
t.Helper()
|
||||
if p.Banner == nil {
|
||||
t.Fatalf("%s: no banner", what)
|
||||
}
|
||||
if len(p.Banner.Campaigns) != 1 {
|
||||
t.Fatalf("%s: %d campaigns, want only the urgent one (default preempted)", what, len(p.Banner.Campaigns))
|
||||
}
|
||||
c := p.Banner.Campaigns[0]
|
||||
if len(c.Messages) != 1 || c.Messages[0] != "System maintenance tonight" {
|
||||
t.Fatalf("%s: messages = %v, want only the urgent one", what, c.Messages)
|
||||
}
|
||||
if c.OverrideBg != "#aa0000" {
|
||||
t.Fatalf("%s: override_bg = %q, want #aa0000", what, c.OverrideBg)
|
||||
}
|
||||
}
|
||||
|
||||
// A normal viewer sees only the urgent campaign (the default is preempted).
|
||||
assertUrgentOnly("eligible", get())
|
||||
|
||||
// The no_banner role no longer suppresses it — urgent reaches everyone.
|
||||
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||
t.Fatalf("grant role: %v", err)
|
||||
}
|
||||
assertUrgentOnly("no_banner", get())
|
||||
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||
t.Fatalf("revoke role: %v", err)
|
||||
}
|
||||
|
||||
// A non-empty hint wallet no longer suppresses it either.
|
||||
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
|
||||
t.Fatalf("grant hints: %v", err)
|
||||
}
|
||||
assertUrgentOnly("hints", get())
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ func TestEmailLoginFlow(t *testing.T) {
|
||||
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
|
||||
email := "login-" + uuid.NewString() + "@example.com"
|
||||
|
||||
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en")
|
||||
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en", false)
|
||||
if err != nil {
|
||||
t.Fatalf("request login code: %v", err)
|
||||
}
|
||||
@@ -233,7 +233,7 @@ func TestEmailLoginFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
// A second login for the same email is the returning user: same account.
|
||||
if _, err := svc.RequestLoginCode(ctx, email, "", "ru"); err != nil {
|
||||
if _, err := svc.RequestLoginCode(ctx, email, "", "ru", false); err != nil {
|
||||
t.Fatalf("second request: %v", err)
|
||||
}
|
||||
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
||||
@@ -255,7 +255,7 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
|
||||
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||
email := "squat-" + uuid.NewString() + "@example.com"
|
||||
|
||||
id, err := svc.RequestLoginCode(ctx, email, "", "en")
|
||||
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
|
||||
if err != nil {
|
||||
t.Fatalf("request login code: %v", err)
|
||||
}
|
||||
@@ -279,6 +279,34 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailLoginPWAOmitsLink: a login requested from an installed PWA (pwa=true) mails only the
|
||||
// code — no one-tap confirm link, which would otherwise open in a separate browser out of the
|
||||
// PWA's reach. A non-PWA request keeps the link.
|
||||
func TestEmailLoginPWAOmitsLink(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
|
||||
|
||||
pwaEmail := "pwa-login-" + uuid.NewString() + "@example.com"
|
||||
if _, err := svc.RequestLoginCode(ctx, pwaEmail, "", "en", true); err != nil {
|
||||
t.Fatalf("pwa request login: %v", err)
|
||||
}
|
||||
if sixDigit.FindString(mailer.lastBody) == "" {
|
||||
t.Errorf("pwa login mail must still carry the code, body %q", mailer.lastBody)
|
||||
}
|
||||
if confirmToken.MatchString(mailer.lastBody) {
|
||||
t.Errorf("pwa login mail must omit the one-tap confirm link, body %q", mailer.lastBody)
|
||||
}
|
||||
|
||||
webEmail := "web-login-" + uuid.NewString() + "@example.com"
|
||||
if _, err := svc.RequestLoginCode(ctx, webEmail, "", "en", false); err != nil {
|
||||
t.Fatalf("web request login: %v", err)
|
||||
}
|
||||
if !confirmToken.MatchString(mailer.lastBody) {
|
||||
t.Errorf("non-pwa login mail must keep the one-tap confirm link, body %q", mailer.lastBody)
|
||||
}
|
||||
}
|
||||
|
||||
// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/<token> link
|
||||
// the branded email carries.
|
||||
var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`)
|
||||
@@ -301,7 +329,7 @@ func TestConfirmByTokenLogin(t *testing.T) {
|
||||
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||
email := "tok-login-" + uuid.NewString() + "@example.com"
|
||||
|
||||
id, err := svc.RequestLoginCode(ctx, email, "", "en")
|
||||
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
|
||||
if err != nil {
|
||||
t.Fatalf("request login: %v", err)
|
||||
}
|
||||
@@ -382,7 +410,7 @@ func TestEmailAccountSeedsDisplayName(t *testing.T) {
|
||||
svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru")
|
||||
local := "kaya-" + uuid.NewString()[:8]
|
||||
|
||||
id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en")
|
||||
id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en", false)
|
||||
if err != nil {
|
||||
t.Fatalf("request login: %v", err)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
func TestAccountLinkFreeEmail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -355,3 +393,64 @@ func TestAccountLinkGuestInversion(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,53 @@ func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, extern
|
||||
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
|
||||
// durable), runs the data merge, retires the secondary's sessions and mints a new
|
||||
// session when the active account switches.
|
||||
|
||||
@@ -22,4 +22,11 @@ type AdCampaigns struct {
|
||||
EndsAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
OverrideBg *string
|
||||
OverrideFg *string
|
||||
OverrideLink *string
|
||||
OverrideBgDark *string
|
||||
OverrideFgDark *string
|
||||
OverrideLinkDark *string
|
||||
Urgent bool
|
||||
}
|
||||
|
||||
@@ -26,6 +26,13 @@ type adCampaignsTable struct {
|
||||
EndsAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
OverrideBg postgres.ColumnString
|
||||
OverrideFg postgres.ColumnString
|
||||
OverrideLink postgres.ColumnString
|
||||
OverrideBgDark postgres.ColumnString
|
||||
OverrideFgDark postgres.ColumnString
|
||||
OverrideLinkDark postgres.ColumnString
|
||||
Urgent postgres.ColumnBool
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -76,9 +83,16 @@ func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTab
|
||||
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
OverrideBgColumn = postgres.StringColumn("override_bg")
|
||||
OverrideFgColumn = postgres.StringColumn("override_fg")
|
||||
OverrideLinkColumn = postgres.StringColumn("override_link")
|
||||
OverrideBgDarkColumn = postgres.StringColumn("override_bg_dark")
|
||||
OverrideFgDarkColumn = postgres.StringColumn("override_fg_dark")
|
||||
OverrideLinkDarkColumn = postgres.StringColumn("override_link_dark")
|
||||
UrgentColumn = postgres.BoolColumn("urgent")
|
||||
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
|
||||
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
|
||||
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn, UrgentColumn}
|
||||
)
|
||||
|
||||
return adCampaignsTable{
|
||||
@@ -94,6 +108,13 @@ func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTab
|
||||
EndsAt: EndsAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
OverrideBg: OverrideBgColumn,
|
||||
OverrideFg: OverrideFgColumn,
|
||||
OverrideLink: OverrideLinkColumn,
|
||||
OverrideBgDark: OverrideBgDarkColumn,
|
||||
OverrideFgDark: OverrideFgDarkColumn,
|
||||
OverrideLinkDark: OverrideLinkDarkColumn,
|
||||
Urgent: UrgentColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -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])));
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Per-campaign banner colour overrides and the urgent (always-show) flag.
|
||||
--
|
||||
-- ad_campaigns gains two optional colour sets and an urgent flag, all for
|
||||
-- non-default campaigns only:
|
||||
-- override_bg/fg/link — paints the strip on every theme
|
||||
-- override_bg/fg/link_dark — further overrides the dark theme
|
||||
-- urgent — force the banner on every viewer (bypassing
|
||||
-- eligibility) and suppress all non-urgent
|
||||
-- campaigns while any urgent one is active
|
||||
--
|
||||
-- Each colour set is all-or-nothing (bg+fg+link together, or absent) and every
|
||||
-- colour is a "#rrggbb" hex string. The default (house) campaign stays plain: no
|
||||
-- colours, never urgent.
|
||||
--
|
||||
-- Expand-contract: additive (nullable columns plus one NOT NULL boolean with a
|
||||
-- default), so a backend image rollback stays DB-safe — older code ignores them.
|
||||
-- The ad_campaigns table shape changes, so its generated go-jet model is
|
||||
-- regenerated (by hand here, to keep the diff to this one table).
|
||||
|
||||
-- +goose Up
|
||||
ALTER TABLE backend.ad_campaigns
|
||||
ADD COLUMN override_bg text,
|
||||
ADD COLUMN override_fg text,
|
||||
ADD COLUMN override_link text,
|
||||
ADD COLUMN override_bg_dark text,
|
||||
ADD COLUMN override_fg_dark text,
|
||||
ADD COLUMN override_link_dark text,
|
||||
ADD COLUMN urgent boolean DEFAULT false NOT NULL,
|
||||
ADD CONSTRAINT ad_campaigns_override_all_chk CHECK (
|
||||
(override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL)
|
||||
OR (override_bg IS NOT NULL AND override_fg IS NOT NULL AND override_link IS NOT NULL)
|
||||
),
|
||||
ADD CONSTRAINT ad_campaigns_override_dark_chk CHECK (
|
||||
(override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL)
|
||||
OR (override_bg_dark IS NOT NULL AND override_fg_dark IS NOT NULL AND override_link_dark IS NOT NULL)
|
||||
),
|
||||
ADD CONSTRAINT ad_campaigns_override_hex_chk CHECK (
|
||||
(override_bg IS NULL OR override_bg ~ '^#[0-9a-fA-F]{6}$')
|
||||
AND (override_fg IS NULL OR override_fg ~ '^#[0-9a-fA-F]{6}$')
|
||||
AND (override_link IS NULL OR override_link ~ '^#[0-9a-fA-F]{6}$')
|
||||
AND (override_bg_dark IS NULL OR override_bg_dark ~ '^#[0-9a-fA-F]{6}$')
|
||||
AND (override_fg_dark IS NULL OR override_fg_dark ~ '^#[0-9a-fA-F]{6}$')
|
||||
AND (override_link_dark IS NULL OR override_link_dark ~ '^#[0-9a-fA-F]{6}$')
|
||||
),
|
||||
ADD CONSTRAINT ad_campaigns_default_plain_chk CHECK (
|
||||
(NOT is_default)
|
||||
OR (override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL
|
||||
AND override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL
|
||||
AND NOT urgent)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE backend.ad_campaigns
|
||||
DROP COLUMN override_bg,
|
||||
DROP COLUMN override_fg,
|
||||
DROP COLUMN override_link,
|
||||
DROP COLUMN override_bg_dark,
|
||||
DROP COLUMN override_fg_dark,
|
||||
DROP COLUMN override_link_dark,
|
||||
DROP COLUMN urgent;
|
||||
@@ -0,0 +1,146 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// The client-side offline robot (ui/src/lib/robot) reproduces the robot's move choice
|
||||
// so a local vs_ai game plays the same way as the server. These golden fixtures pin that
|
||||
// TS port to the real Go strategy. Being in-package, this emitter can exercise the
|
||||
// unexported mix / playToWin / deviates / selectMove that the port mirrors.
|
||||
|
||||
type mixCase struct {
|
||||
Seed string `json:"seed"`
|
||||
Salt string `json:"salt"`
|
||||
Nums []int `json:"nums"`
|
||||
Value string `json:"value"` // the mix() uint64, as a decimal string (exceeds JS safe ints)
|
||||
}
|
||||
|
||||
type decisionCase struct {
|
||||
Seed string `json:"seed"`
|
||||
MoveCount int `json:"moveCount"`
|
||||
MyScore int `json:"myScore"`
|
||||
OppScore int `json:"oppScore"`
|
||||
CandScores []int `json:"candScores"`
|
||||
RackLen int `json:"rackLen"`
|
||||
BagLen int `json:"bagLen"`
|
||||
PlayToWin bool `json:"playToWin"`
|
||||
Win bool `json:"win"` // the per-turn intent after the deviate flip
|
||||
Kind string `json:"kind"`
|
||||
Index int `json:"index"` // chosen candidate index for a play, else -1
|
||||
}
|
||||
|
||||
type strategyFixture struct {
|
||||
PlayToWinPercent int `json:"playToWinPercent"`
|
||||
Mix []mixCase `json:"mix"`
|
||||
Decisions []decisionCase `json:"decisions"`
|
||||
}
|
||||
|
||||
// scenario is one selectMove situation exercised across several seeds.
|
||||
type scenario struct {
|
||||
moveCount, myScore, oppScore, rackLen, bagLen int
|
||||
cands []int
|
||||
}
|
||||
|
||||
// TestEmitStrategyFixtures regenerates ui/src/lib/robot/testdata/strategy.json. It is
|
||||
// gated by EMIT_STRATEGY_FIXTURES so a normal `go test` skips it; the committed output is
|
||||
// what the TS parity test consumes. Regenerate with:
|
||||
//
|
||||
// EMIT_STRATEGY_FIXTURES=1 go test ./backend/internal/robot -run TestEmitStrategyFixtures
|
||||
func TestEmitStrategyFixtures(t *testing.T) {
|
||||
if os.Getenv("EMIT_STRATEGY_FIXTURES") == "" {
|
||||
t.Skip("set EMIT_STRATEGY_FIXTURES=1 to regenerate ui/src/lib/robot/testdata/strategy.json")
|
||||
}
|
||||
|
||||
seeds := []int64{1, 42, -7, 1000003, 9999999999, 123456789, -123456789}
|
||||
|
||||
mixCases := []mixCase{}
|
||||
addMix := func(seed int64, salt string, nums ...int) {
|
||||
mixCases = append(mixCases, mixCase{
|
||||
Seed: strconv.FormatInt(seed, 10),
|
||||
Salt: salt,
|
||||
Nums: append([]int{}, nums...),
|
||||
Value: strconv.FormatUint(mix(seed, salt, nums...), 10),
|
||||
})
|
||||
}
|
||||
for _, sd := range seeds {
|
||||
addMix(sd, "win")
|
||||
addMix(sd, "deviate", 0)
|
||||
addMix(sd, "deviate", 7)
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{3, 40, 55, 7, 20, []int{30, 12, 8}}, // behind, mid-game
|
||||
{10, 120, 90, 7, 8, []int{25, 18, 5}}, // ahead, late
|
||||
{1, 0, 0, 7, 30, []int{60, 30, 15, 7}}, // first move, big spread
|
||||
{20, 200, 40, 5, 2, []int{50, 20}}, // big lead, bag near empty (no deviate)
|
||||
{5, 30, 30, 7, 14, []int{20, 20, 20}}, // equal margins (tie-break)
|
||||
{8, 50, 60, 7, 20, []int{}}, // no play, bag can refill -> exchange
|
||||
{8, 50, 60, 3, 2, []int{}}, // no play, bag can't refill -> pass
|
||||
{15, 300, 10, 7, 6, []int{80, 40, 10}}, // overshoots the band -> nearest to +30
|
||||
}
|
||||
|
||||
decisions := []decisionCase{}
|
||||
for _, sd := range seeds {
|
||||
for _, sc := range scenarios {
|
||||
cands := make([]engine.MoveRecord, len(sc.cands))
|
||||
for i, s := range sc.cands {
|
||||
cands[i] = engine.MoveRecord{Score: s, Player: i}
|
||||
}
|
||||
rack := make([]string, sc.rackLen)
|
||||
for i := range rack {
|
||||
rack[i] = "a"
|
||||
}
|
||||
|
||||
ptw := playToWin(sd)
|
||||
win := ptw
|
||||
if deviates(sd, sc.moveCount, sc.bagLen) {
|
||||
win = !win
|
||||
}
|
||||
d := selectMove(cands, sc.myScore, sc.oppScore, win, defaultBand, rack, sc.bagLen)
|
||||
|
||||
kind, index := "pass", -1
|
||||
switch d.kind {
|
||||
case decidePlay:
|
||||
kind, index = "play", d.move.Player
|
||||
case decideExchange:
|
||||
kind = "exchange"
|
||||
}
|
||||
|
||||
decisions = append(decisions, decisionCase{
|
||||
Seed: strconv.FormatInt(sd, 10),
|
||||
MoveCount: sc.moveCount,
|
||||
MyScore: sc.myScore,
|
||||
OppScore: sc.oppScore,
|
||||
CandScores: append([]int{}, sc.cands...),
|
||||
RackLen: sc.rackLen,
|
||||
BagLen: sc.bagLen,
|
||||
PlayToWin: ptw,
|
||||
Win: win,
|
||||
Kind: kind,
|
||||
Index: index,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fx := strategyFixture{PlayToWinPercent: playToWinPercent, Mix: mixCases, Decisions: decisions}
|
||||
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "robot", "testdata")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
data, err := json.MarshalIndent(fx, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "strategy.json")
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
t.Logf("wrote %s (%d mix, %d decisions)", path, len(mixCases), len(decisions))
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/ads"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
@@ -22,10 +23,20 @@ type bannerDTO struct {
|
||||
|
||||
// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show
|
||||
// weight (for the client's smooth weighted round-robin) and its messages, in
|
||||
// display order, already resolved to one language.
|
||||
// display order, already resolved to one language. The override_* colours are
|
||||
// present only for a campaign that carries a colour override: override_bg/fg/link
|
||||
// paint every theme, and the *_dark trio further overrides the dark theme (the
|
||||
// client resolves the cascade). They are absent for a plain campaign, which keeps
|
||||
// the neutral theme tokens.
|
||||
type bannerCampaignDTO struct {
|
||||
Weight int `json:"weight"`
|
||||
Messages []string `json:"messages"`
|
||||
OverrideBg string `json:"override_bg,omitempty"`
|
||||
OverrideFg string `json:"override_fg,omitempty"`
|
||||
OverrideLink string `json:"override_link,omitempty"`
|
||||
OverrideBgDark string `json:"override_bg_dark,omitempty"`
|
||||
OverrideFgDark string `json:"override_fg_dark,omitempty"`
|
||||
OverrideLinkDark string `json:"override_link_dark,omitempty"`
|
||||
}
|
||||
|
||||
// bannerTimingsDTO mirrors ads.Timings on the wire.
|
||||
@@ -46,9 +57,34 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
|
||||
r := profileResponseFor(acc)
|
||||
r.Banner = s.bannerFor(ctx, acc)
|
||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||
r.DictVersions = s.currentDictVersions()
|
||||
return r
|
||||
}
|
||||
|
||||
// currentDictVersions reports the current dictionary version of every game variant with a
|
||||
// resident dictionary, as engine.Variant stable labels. It backs profileResponse.DictVersions
|
||||
// so an offline-capable client preloads the matching dawg per variant off the cold-start
|
||||
// profile. A variant without a loaded dictionary is omitted (Registry.Latest reports
|
||||
// ErrUnknownVariant); the returned slice is nil only when no variant is resident.
|
||||
func (s *Server) currentDictVersions() []dictVersion {
|
||||
if s.registry == nil {
|
||||
return nil // no dictionary registry wired (e.g. a minimal test server): advertise none
|
||||
}
|
||||
variants := []engine.Variant{engine.VariantEnglish, engine.VariantRussianScrabble, engine.VariantErudit}
|
||||
out := make([]dictVersion, 0, len(variants))
|
||||
for _, v := range variants {
|
||||
version, _, err := s.registry.Latest(v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, dictVersion{Variant: v.String(), Version: version})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -83,6 +119,15 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
||||
if s.ads == nil {
|
||||
return nil
|
||||
}
|
||||
set, timings, urgent, err := s.ads.ActiveSet(ctx, bannerLang(acc))
|
||||
if err != nil {
|
||||
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
// An urgent campaign is shown to every viewer; otherwise the normal eligibility
|
||||
// gate applies (a paid account, a non-empty hint wallet or the no_banner role
|
||||
// hides the banner).
|
||||
if !urgent {
|
||||
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
|
||||
if err != nil {
|
||||
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
@@ -91,14 +136,10 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
||||
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
|
||||
return nil
|
||||
}
|
||||
set, timings, err := s.ads.ActiveSet(ctx, bannerLang(acc))
|
||||
if err != nil {
|
||||
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
campaigns := make([]bannerCampaignDTO, 0, len(set))
|
||||
for _, c := range set {
|
||||
campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages})
|
||||
campaigns = append(campaigns, bannerCampaignFromActive(c))
|
||||
}
|
||||
return &bannerDTO{
|
||||
Campaigns: campaigns,
|
||||
@@ -113,6 +154,20 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
||||
}
|
||||
}
|
||||
|
||||
// bannerCampaignFromActive flattens a resolved campaign into its wire DTO,
|
||||
// projecting each optional colour set into its three "#rrggbb" fields (empty when
|
||||
// the set is absent, so JSON omitempty drops them).
|
||||
func bannerCampaignFromActive(c ads.ActiveCampaign) bannerCampaignDTO {
|
||||
d := bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages}
|
||||
if c.OverrideAll != nil {
|
||||
d.OverrideBg, d.OverrideFg, d.OverrideLink = c.OverrideAll.Bg, c.OverrideAll.Fg, c.OverrideAll.Link
|
||||
}
|
||||
if c.OverrideDark != nil {
|
||||
d.OverrideBgDark, d.OverrideFgDark, d.OverrideLinkDark = c.OverrideDark.Bg, c.OverrideDark.Fg, c.OverrideDark.Link
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// bannerLang resolves the message language for a viewer: their interface language
|
||||
// (Russian, or English by default).
|
||||
func bannerLang(acc account.Account) string {
|
||||
|
||||
@@ -63,6 +63,20 @@ type profileResponse struct {
|
||||
Email string `json:"email"`
|
||||
TelegramLinked bool `json:"telegram_linked"`
|
||||
VkLinked bool `json:"vk_linked"`
|
||||
// DictVersions lists the current dictionary version per game variant (engine.Variant
|
||||
// stable labels). An installed PWA preparing for offline play reads it to preload the
|
||||
// right dawg per enabled variant off this cold-start response, without an extra request.
|
||||
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
|
||||
// for callers that build the DTO without a Server. See Server.profileResponse.
|
||||
DictVersions []dictVersion `json:"dict_versions,omitempty"`
|
||||
}
|
||||
|
||||
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
|
||||
// dictionary version. profileResponse.DictVersions carries the set so an offline-capable
|
||||
// client preloads the matching dawg per variant.
|
||||
type dictVersion struct {
|
||||
Variant string `json:"variant"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// tileDTO is one placed (or to-place) tile.
|
||||
@@ -155,6 +169,9 @@ type stateDTO struct {
|
||||
BagLen int `json:"bag_len"`
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
WalletBalance int `json:"wallet_balance"`
|
||||
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
|
||||
// game / first move / not your turn). The client anchors a monotonic countdown to it.
|
||||
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
||||
}
|
||||
|
||||
@@ -307,6 +324,7 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
||||
BagLen: v.BagLen,
|
||||
HintsRemaining: v.HintsRemaining,
|
||||
WalletBalance: v.WalletBalance,
|
||||
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
||||
}
|
||||
if includeAlphabet {
|
||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||
|
||||
@@ -74,6 +74,8 @@ func (s *Server) registerRoutes() {
|
||||
u.POST("/link/email/merge", s.handleLinkEmailMerge)
|
||||
u.POST("/link/telegram", s.handleLinkTelegram)
|
||||
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
|
||||
@@ -237,6 +239,9 @@ func statusForError(err error) (int, string) {
|
||||
return http.StatusConflict, "no_hint_available"
|
||||
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
|
||||
return http.StatusConflict, "hint_unavailable"
|
||||
case errors.Is(err, game.ErrHintLocked):
|
||||
// A vs_ai idle hint is still gated (the server-clock backstop); the client shows the lock.
|
||||
return http.StatusConflict, "hint_locked"
|
||||
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
||||
return http.StatusUnprocessableEntity, "illegal_play"
|
||||
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
||||
|
||||
@@ -19,6 +19,15 @@ const bannersBack = "/_gm/banners"
|
||||
// bound; the operator's entry is interpreted as UTC.
|
||||
const localTimeLayout = "2006-01-02T15:04"
|
||||
|
||||
// Neutral banner theme tokens, mirrored from ui/src/app.css (--ad-bg,
|
||||
// --text-muted, --accent for the light and dark themes). They seed the console's
|
||||
// colour pickers and the preview fallback when a campaign has no override, so the
|
||||
// operator always edits against the real neutral colours.
|
||||
const (
|
||||
tokenLightBg, tokenLightFg, tokenLightLink = "#e3e7ee", "#6b7280", "#2f6df6"
|
||||
tokenDarkBg, tokenDarkFg, tokenDarkLink = "#272f3c", "#9aa3b2", "#5b8cff"
|
||||
)
|
||||
|
||||
// consoleBanners lists the advertising campaigns (default first), each with its
|
||||
// weight, validity window, enabled flag, message count and live-now status.
|
||||
func (s *Server) consoleBanners(c *gin.Context) {
|
||||
@@ -63,7 +72,12 @@ func (s *Server) consoleBannerDetail(c *gin.Context) {
|
||||
Enabled: cm.Enabled,
|
||||
StartsAt: localInput(cm.StartsAt),
|
||||
EndsAt: localInput(cm.EndsAt),
|
||||
Urgent: cm.Urgent,
|
||||
OverrideAllOn: cm.OverrideAll != nil,
|
||||
OverrideDarkOn: cm.OverrideDark != nil,
|
||||
}
|
||||
view.AllBg, view.AllFg, view.AllLink = seedColors(cm.OverrideAll, tokenLightBg, tokenLightFg, tokenLightLink)
|
||||
view.DarkBg, view.DarkFg, view.DarkLink = seedColors(cm.OverrideDark, tokenDarkBg, tokenDarkFg, tokenDarkLink)
|
||||
for i, m := range cm.Messages {
|
||||
view.Messages = append(view.Messages, adminconsole.BannerMessageRow{
|
||||
ID: m.ID.String(),
|
||||
@@ -117,6 +131,9 @@ func (s *Server) consoleUpdateBanner(c *gin.Context) {
|
||||
Enabled: c.PostForm("enabled") != "",
|
||||
StartsAt: starts,
|
||||
EndsAt: ends,
|
||||
Urgent: c.PostForm("urgent") != "",
|
||||
OverrideAll: colorSetForm(c, "override_all_on", "override_bg", "override_fg", "override_link"),
|
||||
OverrideDark: colorSetForm(c, "override_dark_on", "override_bg_dark", "override_fg_dark", "override_link_dark"),
|
||||
})
|
||||
if err != nil {
|
||||
s.bannerError(c, err, back)
|
||||
@@ -321,3 +338,29 @@ func atoiForm(c *gin.Context, name string) int {
|
||||
n, _ := strconv.Atoi(trimForm(c, name))
|
||||
return n
|
||||
}
|
||||
|
||||
// seedColors returns the three colour-input seed values for a campaign's optional
|
||||
// override: the stored colours when the set is present, otherwise the given
|
||||
// neutral theme tokens (so the native picker starts sensibly and the preview
|
||||
// shows the real fallback).
|
||||
func seedColors(cs *ads.ColorSet, tokenBg, tokenFg, tokenLink string) (bg, fg, link string) {
|
||||
if cs == nil {
|
||||
return tokenBg, tokenFg, tokenLink
|
||||
}
|
||||
return cs.Bg, cs.Fg, cs.Link
|
||||
}
|
||||
|
||||
// colorSetForm reads an optional colour override from the form: the on flag gates
|
||||
// it (an unchecked set is absent, nil), otherwise the three posted colours are
|
||||
// returned for the ads service to validate. The colour inputs are disabled in the
|
||||
// browser while the set is off, so they are not posted then.
|
||||
func colorSetForm(c *gin.Context, on, bg, fg, link string) *ads.ColorSet {
|
||||
if c.PostForm(on) == "" {
|
||||
return nil
|
||||
}
|
||||
return &ads.ColorSet{
|
||||
Bg: trimForm(c, bg),
|
||||
Fg: trimForm(c, fg),
|
||||
Link: trimForm(c, link),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,10 @@ type emailRequest struct {
|
||||
Email string `json:"email"`
|
||||
BrowserTZ string `json:"browser_tz"`
|
||||
Language string `json:"language"`
|
||||
// Pwa marks a request from an installed PWA (standalone display mode): the login email then
|
||||
// omits the one-tap confirm link so the code is typed in the same window (the link would open
|
||||
// in a separate browser whose session cannot reach the PWA). See EmailService.RequestLoginCode.
|
||||
Pwa bool `json:"pwa"`
|
||||
}
|
||||
|
||||
// handleEmailRequest issues a login confirm-code to the email. It always reports
|
||||
@@ -185,7 +189,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
|
||||
abortBadRequest(c, "email is required")
|
||||
return
|
||||
}
|
||||
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language); err != nil {
|
||||
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language, req.Pwa); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ type linkTelegramBody struct {
|
||||
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
|
||||
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
|
||||
// account — the secondary_* fields summarise it for the irreversible confirmation),
|
||||
@@ -233,6 +239,48 @@ func (s *Server) handleLinkTelegramMerge(c *gin.Context) {
|
||||
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)
|
||||
// or a completed link (the active account's refreshed profile).
|
||||
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
||||
|
||||
+34
-5
@@ -1,6 +1,10 @@
|
||||
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
|
||||
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; the prod
|
||||
# deploy maps the PROD_-prefixed set the same way. 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.
|
||||
|
||||
@@ -15,8 +19,9 @@ POSTGRES_PASSWORD=change-me # required
|
||||
# boot the dawg-data volume preserves versions uploaded through the admin console and
|
||||
# the active version lives in the DB. On a live volume a changed value is ignored (the
|
||||
# recorded .seed_version marker wins — the seed-drift guard); change a running
|
||||
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
|
||||
DICT_VERSION=v1.3.0
|
||||
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). One shared Gitea
|
||||
# variable (DICT_VERSION) seeds both contours + pins the CI test suite.
|
||||
DICT_VERSION=v1.3.1
|
||||
|
||||
# --- Logging ----------------------------------------------------------------
|
||||
LOG_LEVEL=info
|
||||
@@ -45,6 +50,17 @@ SMTP_RELAY_PASS= # secret
|
||||
SMTP_RELAY_FROM=no-reply@erudit-game.ru
|
||||
PUBLIC_BASE_URL= # required when SMTP_RELAY_HOST is set (e.g. https://erudit-game.ru)
|
||||
|
||||
# Operator alerts (email). The backend emails the admin on new feedback / word complaints
|
||||
# (coalesced), and Grafana emails infra alerts. Distinct senders; recipients may be several
|
||||
# comma-separated addresses. Grafana reuses SMTP_RELAY_HOST/USER/PASS but dials the STARTTLS
|
||||
# port (it can't do the backend's implicit TLS), GRAFANA_SMTP_PORT. All empty = off.
|
||||
SMTP_RELAY_ADMIN_FROM= # backend admin-alert From (e.g. alerts@erudit-game.ru)
|
||||
ADMIN_EMAIL= # backend admin-alert recipient(s), comma-separated
|
||||
SMTP_RELAY_SERVICE_FROM= # Grafana alert From; the deploy derives a bare address for Grafana (it rejects "Name" <addr>)
|
||||
SERVICE_EMAIL= # Grafana alert recipient(s), comma-separated
|
||||
GRAFANA_SMTP_PORT= # Grafana STARTTLS port on SMTP_RELAY_HOST (Selectel: 1126)
|
||||
GF_SMTP_ENABLED=false # set true to enable Grafana alert emails
|
||||
|
||||
# --- Edge / caddy -----------------------------------------------------------
|
||||
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
|
||||
# external `edge` network). Prod: a domain so caddy does its own ACME.
|
||||
@@ -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_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
||||
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
||||
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
||||
VITE_VK_ID_REDIRECT_URL= # VK ID trusted redirect URL (e.g. https://erudit-game.ru/app/); also the gateway's exchange redirect_uri. Deploy derives it as PUBLIC_BASE_URL + /app/
|
||||
VITE_GATEWAY_URL=
|
||||
|
||||
# --- Grafana ----------------------------------------------------------------
|
||||
GRAFANA_ROOT_URL=/_gm/grafana/ # 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
|
||||
|
||||
# --- 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_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
|
||||
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
|
||||
TELEGRAM_MINIAPP_URL= # required
|
||||
TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_BASE_URL + /telegram/
|
||||
TELEGRAM_TEST_ENV=false
|
||||
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).
|
||||
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
|
||||
GATEWAY_VK_APP_SECRET=
|
||||
# VK ID web login (browser VK-identity linking) — a SEPARATE VK "Web" app: the gateway runs
|
||||
# the confidential OAuth 2.1 code exchange under this protected key. The app id + redirect URL
|
||||
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
||||
GATEWAY_VK_ID_CLIENT_SECRET=
|
||||
|
||||
# --- 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`
|
||||
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`,
|
||||
`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
|
||||
@@ -62,7 +74,7 @@ compose binds from this directory.
|
||||
| --- | --- | --- |
|
||||
| `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>'`. |
|
||||
| `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`. |
|
||||
|
||||
**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_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`). |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
@@ -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_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_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). |
|
||||
| `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_PORT` | variable | `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_USER` | secret | _(empty)_ | Relay SMTP AUTH username. |
|
||||
| `SMTP_RELAY_PASS` | secret | _(empty)_ | Relay SMTP AUTH password. |
|
||||
| `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 (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||
| `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 (shared) | _(empty)_ | Relay SMTP AUTH username. |
|
||||
| `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. |
|
||||
| `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
|
||||
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
|
||||
[`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
|
||||
move the whole stack — change both to a new release:
|
||||
a build-time input with **no default** in the images. It is a single Gitea repo variable —
|
||||
**`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
|
||||
download that dawg).
|
||||
2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag
|
||||
the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as
|
||||
`DICT_VERSION`).
|
||||
- the CI test jobs read it via `.gitea/workflows/ci.yaml`'s top-level
|
||||
`env.DICT_VERSION: ${{ vars.DICT_VERSION }}` (the unit/integration jobs download that dawg), and
|
||||
- both deploy jobs feed the same variable to `compose` as the `DICT_VERSION` build-arg that
|
||||
bakes a **fresh** volume's seed.
|
||||
|
||||
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
|
||||
@@ -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 →
|
||||
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
|
||||
`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
|
||||
@@ -200,18 +232,31 @@ together with the fresh CA.
|
||||
**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
|
||||
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,
|
||||
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
|
||||
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY,
|
||||
SMTP_RELAY_USER, SMTP_RELAY_PASS}`; variables:
|
||||
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
|
||||
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
|
||||
TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK,
|
||||
SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, SMTP_RELAY_FROM, PUBLIC_BASE_URL}`.
|
||||
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, GATEWAY_HONEYTOKEN, REGISTRY_PASSWORD, SSH_KEY,
|
||||
SSH_KNOWN_HOSTS, BOTLINK_CA, BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT,
|
||||
BOTLINK_BOT_KEY}`; variables:
|
||||
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, LOG_LEVEL,
|
||||
TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_BOT_USERNAME,
|
||||
VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, VITE_TELEGRAM_GAME_CHANNEL_NAME, SMTP_RELAY_FROM,
|
||||
PUBLIC_BASE_URL, SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL,
|
||||
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)
|
||||
|
||||
|
||||
@@ -19,3 +19,11 @@ scrabble_base_dir: /opt/scrabble
|
||||
# the host's own containers (and any ad-hoc runs) rotate identically.
|
||||
docker_log_max_size: "10m"
|
||||
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
|
||||
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 --------------------------------------------------------
|
||||
|
||||
- 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
|
||||
+34
-1
@@ -33,6 +33,39 @@
|
||||
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
|
||||
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.
|
||||
@gm path /_gm /_gm/*
|
||||
handle @gm {
|
||||
@@ -53,7 +86,7 @@
|
||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
||||
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
||||
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
||||
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /scrabble.edge.v1.Gateway/*
|
||||
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
|
||||
handle @gateway {
|
||||
reverse_proxy gateway:8081 {
|
||||
header_up -X-Scrabble-Honeypot
|
||||
|
||||
@@ -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_FROM: ${SMTP_RELAY_FROM:-no-reply@localhost}
|
||||
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 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
|
||||
@@ -186,6 +190,8 @@ services:
|
||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
||||
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_APP_VERSION: ${APP_VERSION:-dev}
|
||||
# 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
|
||||
# the VK auth path (auth.vk is then unregistered).
|
||||
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
|
||||
# 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
|
||||
@@ -213,10 +227,12 @@ services:
|
||||
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
|
||||
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
|
||||
# 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
|
||||
# real in prod — the test contour arrives as one shared NAT address, so a ban
|
||||
# there would be self-inflicted (the honeypot/honeytoken still log). Prod sets
|
||||
# these from PROD_ inputs; GATEWAY_HONEYTOKEN is the planted bearer trap.
|
||||
# honeypot/honeytoken. Off by default: it bans by client IP, which is only real
|
||||
# in prod — the test contour arrives as one shared NAT address, so a ban there
|
||||
# would be self-inflicted (the honeypot still logs). The prod deploy forces it on
|
||||
# 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_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
||||
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
@@ -260,6 +276,8 @@ services:
|
||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
||||
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_APP_VERSION: ${APP_VERSION:-dev}
|
||||
restart: unless-stopped
|
||||
@@ -410,6 +428,11 @@ services:
|
||||
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
|
||||
volumes:
|
||||
- ${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
|
||||
deploy:
|
||||
resources:
|
||||
@@ -497,6 +520,21 @@ services:
|
||||
# caddy's Basic-Auth and re-prompts for the password on every dashboard; the
|
||||
# dashboards poll and do not need Live.
|
||||
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:
|
||||
- ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
# Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the
|
||||
@@ -547,6 +585,24 @@ services:
|
||||
memory: 64M
|
||||
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:
|
||||
internal:
|
||||
name: scrabble-internal
|
||||
|
||||
@@ -56,6 +56,22 @@
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Unsupported-engine screens (cumulative by reason)",
|
||||
"description": "Clients turned away by the index.html boot guard because the engine cannot run the app (an old Android System WebView), by reason: no_bigint / no_proxy (a missing unpolyfillable primitive) or boot_error (an uncaught startup failure). Deduped per device/version, so this counts distinct blocked installs, not launches. The effective floor is Chrome 67 (BigInt).",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 32 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "sum(unsupported_engine_total) by (reason)", "legendFormat": "{{reason}}" }]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Unsupported engines by Chromium (rate)",
|
||||
"description": "The same blocked clients by reported Chromium major version — which old in-app WebViews are still in the wild. \"other\" is an unparseable or out-of-range version.",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 32 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "sum(rate(unsupported_engine_total[1h])) by (chromium)", "legendFormat": "Chromium {{chromium}}" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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_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; }
|
||||
export REGISTRY
|
||||
# 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
|
||||
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.
|
||||
if [ "$MIGRATION" = 1 ]; then
|
||||
echo "migration deploy: opening maintenance window (stopping the backend = the only writer)"
|
||||
|
||||
@@ -23,3 +23,21 @@ scrape_configs:
|
||||
- job_name: node
|
||||
static_configs:
|
||||
- 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
|
||||
+131
-8
@@ -244,7 +244,9 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
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`
|
||||
profile-refresh to the in-app session, a change switches the account's email in place,
|
||||
and a would-be merge is deferred to the interactive flow. The confirm runs on load; the token rides the URL fragment (never
|
||||
and a would-be merge is deferred to the interactive flow. A login requested from an installed **PWA** omits the deeplink
|
||||
entirely — its email carries the code only, typed in the same window, since the link would open
|
||||
in a separate browser whose minted session cannot reach the PWA. The confirm runs on load; the token rides the URL fragment (never
|
||||
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
|
||||
@@ -257,11 +259,19 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
control of the identity before attaching it: **email** through the confirm-code
|
||||
flow, **Telegram** through the web **Login Widget** (validated by the validator,
|
||||
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"
|
||||
signal, so a probe cannot enumerate registered addresses); a required **merge**
|
||||
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`).
|
||||
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
|
||||
backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
|
||||
@@ -284,8 +294,11 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
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 and delete** —
|
||||
writes a `retained_identities` row (`reason`), so the dossier keeps the full timeline.
|
||||
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
|
||||
@@ -969,10 +982,25 @@ edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are o
|
||||
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
|
||||
`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal),
|
||||
and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content*
|
||||
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. The same
|
||||
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. A
|
||||
non-default campaign may also carry an optional **colour override** (background, text, link) in two
|
||||
sets — one for every theme, one for the dark theme only — plus an **urgent** flag. The colours ride
|
||||
the same block (six optional strings appended to each `BannerCampaign` on the wire — trailing,
|
||||
backward-compatible); the client resolves the cascade (dark ← dark ?? all, light ← all) and derives
|
||||
the strip's border from the background in JS (no CSS `color-mix`, for the old-WebView floor).
|
||||
**Urgent is resolved entirely server-side, with no wire field**: while any enabled, in-window urgent
|
||||
campaign exists, `computeActiveSet` returns *only* the urgent campaigns (preempting the timed set and
|
||||
the default remainder) and `bannerFor` **skips the eligibility gate** for that feed, so an urgent
|
||||
notice reaches every viewer — paid, hint-holding or `no_banner` included. There is no instant
|
||||
broadcast on an urgent toggle (the notify path is per-user); an urgent campaign appears on each
|
||||
viewer's next `profile.get`. The default campaign stays plain (no colours, never urgent), enforced by
|
||||
both the service and a DB CHECK. The same
|
||||
mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's
|
||||
own account changed out of band (an email confirmed through the one-tap deeplink opened in another
|
||||
browser), so an open in-app session reflects it at once.
|
||||
browser), so an open in-app session reflects it at once. The live stream is single-shot with no
|
||||
replay, so a **backgrounded Mini App** — suspended while the user is in their mail app tapping the
|
||||
link — misses the event; while an add-email confirmation is pending the client therefore also
|
||||
**polls `profile.get`** (and on foreground regain) as a fallback until the address lands.
|
||||
|
||||
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
|
||||
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
|
||||
@@ -1003,6 +1031,21 @@ browser), so an open in-app session reflects it at once.
|
||||
both are surfaced on the **Scrabble — Resources** Grafana dashboard, which captures the
|
||||
stress-run resource profile. (`docker_stats` replaced cAdvisor, which on the contour
|
||||
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. The digest carries **no admin-console link** — an admin URL must
|
||||
never travel in an email, where a mail provider could cache or index it; the operator opens the
|
||||
console directly. Both paths are inert unless configured.
|
||||
- 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
|
||||
client-measured RTT piggybacked on the next request is a later enhancement.
|
||||
@@ -1046,6 +1089,17 @@ browser), so an open in-app session reflects it at once.
|
||||
it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by
|
||||
design (a dropped beacon just retries its batch on the next flush); it is telemetry, never
|
||||
a game input.
|
||||
- **Unsupported-engine screens (client-reported):** the ES5 boot guard in `index.html` (§13)
|
||||
shows a full-screen "your device's OS or browser can't run the app" screen — instead of a white
|
||||
screen — when the engine lacks an unpolyfillable essential (`BigInt`, the 64-bit FlatBuffers
|
||||
decode; or `Proxy`, Svelte 5 runes) or an uncaught error aborts boot; the effective floor is
|
||||
Chrome 67. It then fires one fire-and-forget beacon (`POST /telemetry/unsupported` —
|
||||
unauthenticated, since the client never booted, but per-IP public-limited and body-capped),
|
||||
deduped in `localStorage` by app version + reason + Chromium so a user reopening the app is one
|
||||
report. The gateway folds it into `unsupported_engine_total` (`reason` =
|
||||
no_bigint/no_proxy/boot_error/other; `chromium` = the major version reduced to a bounded range so
|
||||
a spoofed beacon cannot inflate cardinality) and logs the full user agent (not a label). Surfaced
|
||||
on the **Scrabble — Users** dashboard beside app opens.
|
||||
- **Rate-limit observability:** every limiter rejection increments the gateway
|
||||
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
|
||||
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
|
||||
@@ -1183,7 +1237,76 @@ origin, so the test contour canonicalises to production instead of being indexed
|
||||
separate site; the SPA shell is `noindex`, and the landing always boots in Russian (no
|
||||
browser-language detection — crawlers render with arbitrary languages). The favicon set,
|
||||
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
|
||||
`assets/icons/`, same tile design as the VK loader). Hash-named `/assets/*` are served
|
||||
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
|
||||
`manifest.webmanifest` ships unhashed from `ui/public/`, while the **service worker** (`sw.js`) is
|
||||
built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
|
||||
**web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
|
||||
**precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
|
||||
no network. Shell **navigations are network-first** — the fresh `no-cache` shell is fetched from the
|
||||
server (so a new deploy is picked up on the very next launch, not the one after), falling back to the
|
||||
precached shell only when the network is unreachable or too slow; the immutable hashed assets stay
|
||||
cache-first, and the hash router resolves the route client-side. This navigation route is registered
|
||||
**before** the precache route on purpose: Workbox matches in registration order, and the precache
|
||||
route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve the shell
|
||||
cache-first — the old build's version until a second load. The landing page and the conditional
|
||||
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
|
||||
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
||||
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
||||
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
|
||||
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
|
||||
creates one through the in-browser engine — the same game screen then drives it, the robot replying
|
||||
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
|
||||
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
|
||||
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
|
||||
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
|
||||
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
|
||||
current turn, **exclude** a player (a forced seat resign, `resignSeat`) or **terminate** the game
|
||||
(deleted, no winner/loser); each seat may also carry its own **optional PIN** that withholds its rack
|
||||
(the board stays visible — only the tray is gated) until it is entered, so players pass the device
|
||||
around freely and lock a seat only against a distrusted tablemate. The unlock is **per turn**
|
||||
(re-locks on advance). PINs are a **social lock, not cryptography** — a salted SHA-256 kept only in
|
||||
the device record (`lib/pin`); a 4-digit PIN is trivially brute-forceable and the racks live in
|
||||
client memory regardless, so they defeat a casual glance, not a determined peek. A hotseat game is
|
||||
**not `vs_ai`**: hints (the freed control becomes the host button) and chat/self-resign are gone, and
|
||||
its lobby card — active *and* finished — is master-PIN-gated to delete (so the last mover cannot
|
||||
instantly wipe it); a naturally finished game is saved with its result like any local game. A `vs_ai`
|
||||
hint (online and offline alike) is unlimited and wallet-free but idle-gated
|
||||
(unlocked ~30 min into a stuck turn), and counts toward no hint statistic. The **source** reports the
|
||||
**seconds left** (`hint_unlock_left_seconds` on the game view) — computed by the **backend from the
|
||||
server clock** online (which also enforces the gate, returning `hint_locked` for an early request),
|
||||
and by the offline source from the device clock (persisted, capped at the window). The **client
|
||||
anchors a MONOTONIC countdown** (`performance.now()`, `lib/hints`) to that seconds-left when it lands
|
||||
(on load, and to the full window when the robot moves), so a client clock change cannot skew it, and
|
||||
a relaunch re-reads a fresh value so the wait is not forgotten. To have data ready before the switch, the **Profile advertises the current dictionary
|
||||
version per variant** (`dict_versions`,
|
||||
filled from the registry on the existing cold-start profile request — no extra round-trip), and an
|
||||
eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries
|
||||
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
||||
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
|
||||
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
|
||||
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
|
||||
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
||||
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
||||
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
|
||||
mode** boots from the persisted session and
|
||||
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
||||
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
||||
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
||||
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
|
||||
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
|
||||
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
|
||||
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
|
||||
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
|
||||
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
|
||||
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
|
||||
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
|
||||
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
|
||||
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
|
||||
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
|
||||
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
|
||||
while offline. The gateway registers the `.webmanifest` MIME type
|
||||
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
||||
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
||||
|
||||
+79
-9
@@ -21,7 +21,8 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A
|
||||
costs nothing when the rack has no legal move. The word-check accepts only the
|
||||
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
||||
A public **landing page** at the site root introduces the game, switches language and
|
||||
theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at
|
||||
theme, and links to the Telegram game channel, the VK Mini App and the **web version**
|
||||
(`/app/`) — each under a caption (Telegram / VK / Веб-версия); the game itself runs at
|
||||
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
||||
(it follows the system scheme, not the saved preference); its language choice is saved. The
|
||||
landing always opens in **Russian** — the browser language is never auto-detected (crawlers
|
||||
@@ -31,6 +32,11 @@ nondeterministic); a saved 🌐 choice still wins. The page carries a static Rus
|
||||
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
|
||||
shell is `noindex` — the landing is the only indexable page.
|
||||
|
||||
On the plain web the client is an **installable PWA**: a logged-out player sees an install
|
||||
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
|
||||
home screen / desktop in one tap where the browser supports it (Chromium), or shows manual
|
||||
Add-to-Home-Screen steps on iOS Safari; it is hidden once installed and inside the Mini Apps.
|
||||
|
||||
### First-run onboarding
|
||||
The first time a player opens the app it walks them through the interface with a light
|
||||
**coachmark overlay**: a dimmed layer draws one hint bubble at a time, each pointing — with a
|
||||
@@ -70,7 +76,9 @@ per address), so a mistyped address or an impatient tap cannot flood an inbox. T
|
||||
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
|
||||
mail scanner that merely fetches the link cannot reach it — the token rides the URL
|
||||
fragment, which is never sent to the server.
|
||||
fragment, which is never sent to the server. A sign-in requested from an **installed PWA** omits
|
||||
this link: the email carries only the code, entered in the same window, since the link would open
|
||||
in a separate browser the app cannot reach.
|
||||
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
|
||||
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||||
@@ -108,8 +116,12 @@ account is kept and the guest's games move into it. A merge is blocked only whil
|
||||
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; adding VK on the web is not offered yet, and inside a Mini App the host
|
||||
platform is already linked. A linked provider can be **unlinked** — except the last
|
||||
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, and its own sign-in-method
|
||||
row is hidden — the platform the player is currently signed in through cannot be unlinked
|
||||
from there, only the other provider's row is shown. 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
|
||||
@@ -203,7 +215,16 @@ unlimited and offers a complaint on any result; for a word it finds, it also lin
|
||||
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
||||
English) to look it up. Hints are governed per game —
|
||||
whether they are allowed and how many each player starts with — and draw on a
|
||||
personal hint wallet once the per-game allowance is spent. The game ends when the
|
||||
personal hint wallet once the per-game allowance is spent. Against the robot
|
||||
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
||||
anti-frustration aid: one unlocks only after the player has been **stuck ~30 minutes
|
||||
on a turn** (timed from the robot's last move; the very first move, before the robot
|
||||
has played, is exempt). While gated the hint button carries a small **🔒 lock** and a
|
||||
tap shows how long remains; the lock lifts live at the mark. The same gate applies **online and
|
||||
offline**: the countdown runs on a **steady in-app timer**, not the device clock, so changing the
|
||||
clock cannot skew it, and a relaunch re-reads the remaining time so a stuck turn is not forgotten.
|
||||
Online the **server enforces** it from its own clock (which the player cannot touch); a vs_ai hint
|
||||
counts toward no hint statistic. The game ends when the
|
||||
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
|
||||
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
|
||||
24 hours): a missed turn auto-resigns, except while the player is inside their
|
||||
@@ -244,6 +265,44 @@ the same occasional move against its plan that fades out by the endgame), and ch
|
||||
add-friend are off. AI games are **practice** — they never count toward a player's
|
||||
statistics.
|
||||
|
||||
### Offline mode
|
||||
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
|
||||
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
|
||||
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
|
||||
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
|
||||
A New Game against the robot creates a device-local **vs_ai** game — it
|
||||
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
|
||||
offline mode, and never sync to the account. So a game can be created and played with no connection,
|
||||
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
|
||||
(once installed) launches from a precached shell even with no network. Switching **to** offline first
|
||||
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
|
||||
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
|
||||
(the download keeps running in the background, so a later switch is instant). The mode is
|
||||
device-scoped and sticky across launches.
|
||||
|
||||
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
|
||||
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
||||
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
|
||||
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
|
||||
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
|
||||
During the game the board is always visible, but a PIN-locked seat shows an **Unlock** button in place
|
||||
of its rack until its owner enters the PIN — so the device passes hand to hand and only a protected
|
||||
seat hides its letters; the lock returns each turn. The **leader** (the host button in the game) can,
|
||||
with the leader password, **skip** the current player's turn, **remove** a player, or **end the game
|
||||
early** — an early end discards the game with no winner or loser. Hints and chat are off in a
|
||||
pass-and-play game. A game that finishes normally is saved to the offline lobby like any other;
|
||||
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
|
||||
so no one can wipe a game the moment they have moved.
|
||||
|
||||
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
|
||||
with no connection it switches to offline for that session; when the device is online but the gateway
|
||||
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
|
||||
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
|
||||
the network — airplane mode — switches to offline automatically, and restoring it returns online by
|
||||
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
|
||||
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that
|
||||
dialog — is the player's choice: it is kept, and never undone automatically.
|
||||
|
||||
### Social: friends, block, chat, nudge
|
||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||||
digits, valid for twelve hours), or send a **request to someone you have played
|
||||
@@ -481,8 +540,19 @@ through**, regardless of their interface language. The client rotates the eligib
|
||||
**fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random —
|
||||
and a campaign with several messages shows them in turn.
|
||||
|
||||
A non-default campaign can carry its own **colours** — background, text and link — so a sponsored or
|
||||
operational message stands out from the neutral strip. Colours come in two sets: one that applies on
|
||||
**every theme**, and an optional second that overrides the **dark theme** only, so a campaign reads
|
||||
well on both; the strip's border is derived from the background automatically. A campaign can also be
|
||||
marked **urgent**: an urgent campaign is shown to **everyone** — even paid players, hint holders and
|
||||
`no_banner` users — and, while it is live, it is the **only** thing the strip shows (ordinary
|
||||
campaigns and the house default step aside). Urgent is for genuine system notices (maintenance,
|
||||
outages), not advertising.
|
||||
|
||||
Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable
|
||||
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), and set
|
||||
the global display **timings** (how long a message holds, the scroll of an over-long message, and the
|
||||
fade-out → gap → fade-in transition between messages). The default campaign cannot be deleted and
|
||||
keeps at least one message.
|
||||
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), pick a
|
||||
non-default campaign's optional override colours (a native colour picker with a live light-and-dark
|
||||
preview of the strip) and mark it urgent, and set the global display **timings** (how long a message
|
||||
holds, the scroll of an over-long message, and the fade-out → gap → fade-in transition between
|
||||
messages). The default campaign cannot be deleted, keeps at least one message, and stays plain (no
|
||||
colours, never urgent).
|
||||
|
||||
+79
-8
@@ -22,7 +22,8 @@ top-1 подсказку, безлимитную проверку слова с
|
||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||
и ограничивает частоту повторов.
|
||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||
тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам
|
||||
тему и ведёт в Telegram-канал игры, в VK Mini App и в **веб-версию** (`/app/`) — каждая под
|
||||
подписью (Telegram / VK / Веб-версия); сама игра живёт по адресам
|
||||
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
||||
системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда
|
||||
открывается на **русском** — язык браузера не определяется автоматически (роботы
|
||||
@@ -33,6 +34,11 @@ Open Graph, которую используют превью ссылок в Tel
|
||||
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
||||
`noindex` — индексируется только посадочная страница.
|
||||
|
||||
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
|
||||
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
|
||||
(домашний экран) там, где браузер это умеет (Chromium), либо показывает ручные шаги
|
||||
«На экран „Домой“» в Safari на iOS; он скрыт после установки и внутри Mini App.
|
||||
|
||||
### Первый запуск: онбординг
|
||||
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
|
||||
**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое
|
||||
@@ -74,7 +80,9 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
||||
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
||||
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
||||
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
|
||||
он едет в URL-фрагменте, который не уходит на сервер.
|
||||
он едет в URL-фрагменте, который не уходит на сервер. Вход, запрошенный из **установленного
|
||||
PWA**, эту ссылку не содержит: в письме только код, который вводится в том же окне (ссылка
|
||||
открылась бы в отдельном браузере, недоступном приложению).
|
||||
Telegram держит **единого бота**: все игроки пользуются одним
|
||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||
@@ -112,8 +120,12 @@ Telegram держит **единого бота**: все игроки поль
|
||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||
|
||||
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
||||
Telegram; добавление VK в вебе пока не предлагается, а внутри Mini App платформа-хозяин
|
||||
уже привязана. Привязанного провайдера можно **отвязать** — кроме последнего
|
||||
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
||||
обратно); внутри Mini App платформа-хозяин уже привязана, и её собственная плашка способа
|
||||
входа скрыта — платформу, через которую игрок сейчас вошёл, отсюда отвязать нельзя,
|
||||
показывается только плашка другого провайдера. Привязка провайдера, уже
|
||||
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
|
||||
email. Привязанного провайдера можно **отвязать** — кроме последнего
|
||||
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
|
||||
**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
|
||||
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
|
||||
@@ -210,7 +222,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||
чтобы его посмотреть. Подсказки управляются настройками
|
||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
|
||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
||||
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
||||
анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут**
|
||||
(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт
|
||||
закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось;
|
||||
замок снимается вживую в нужный момент. Один и тот же гейт работает **онлайн и офлайн**: отсчёт
|
||||
идёт по **ровному внутриигровому таймеру**, а не по системным часам, поэтому их перевод его не
|
||||
собьёт, а повторный вход перечитывает остаток — застрявший ход не забывается. Онлайн гейт
|
||||
**принуждает сервер** по своим часам (их игрок не тронет); vs_ai-подсказка не учитывается ни в
|
||||
одной статистике подсказок. Партия
|
||||
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
||||
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
||||
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
||||
@@ -249,6 +270,45 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
|
||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||
|
||||
### Офлайн-режим
|
||||
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
||||
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
||||
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
||||
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
||||
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
||||
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
||||
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
||||
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
||||
сохраняется между запусками.
|
||||
|
||||
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
||||
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||
дать любому месту **свой необязательный PIN**. Во время игры доска видна всегда, но место с PIN
|
||||
вместо стойки показывает кнопку **«Разблокировать»**, пока владелец не введёт PIN, — так устройство
|
||||
передаётся из рук в руки, и только защищённое место прячет свои буквы; на каждом ходу замок
|
||||
возвращается. **Ведущий** (кнопка ведущего в игре) может по паролю ведущего **пропустить** ход
|
||||
текущего игрока, **исключить** игрока или **завершить игру досрочно** — досрочное завершение стирает
|
||||
партию без победителей и проигравших. Подсказки и чат в игре по очереди отключены. Нормально
|
||||
завершённая партия сохраняется в офлайн-лобби как любая другая; удаление любой игры по очереди —
|
||||
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
||||
своего хода.
|
||||
|
||||
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
||||
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
||||
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
||||
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
|
||||
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
|
||||
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
|
||||
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
||||
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
||||
|
||||
### Социальное: друзья, блок, чат, nudge
|
||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
|
||||
@@ -492,8 +552,19 @@ high-rate флага. С карточки пользователя операт
|
||||
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
|
||||
кампания с несколькими сообщениями показывает их по очереди.
|
||||
|
||||
Недефолтная кампания может нести собственные **цвета** — фон, текст и ссылку — чтобы рекламное или
|
||||
служебное сообщение выделялось на фоне нейтральной ленты. Цвета задаются двумя наборами: один
|
||||
применяется на **любой теме**, а необязательный второй переопределяет только **тёмную тему**, чтобы
|
||||
кампания хорошо читалась на обеих; рамка ленты выводится из фона автоматически. Кампанию можно также
|
||||
пометить как **срочную (urgent)**: срочная кампания показывается **всем** — даже платным игрокам,
|
||||
владельцам подсказок и пользователям с ролью `no_banner` — и, пока она активна, лента показывает
|
||||
**только её** (обычные кампании и домашняя дефолтная уступают место). Срочность — для настоящих
|
||||
системных уведомлений (тех.работы, сбои), а не для рекламы.
|
||||
|
||||
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
|
||||
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
|
||||
их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и
|
||||
переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней
|
||||
всегда остаётся хотя бы одно сообщение.
|
||||
их), выбирает необязательные цвета недефолтной кампании (нативный color-picker с живым превью ленты
|
||||
на светлой и тёмной темах) и помечает её срочной, а также задаёт общие **тайминги** показа (сколько
|
||||
держится сообщение, прокрутка слишком длинного, и переход fade-out → пауза → fade-in между
|
||||
сообщениями). Дефолтную кампанию нельзя удалить, в ней всегда остаётся хотя бы одно сообщение, и она
|
||||
остаётся простой (без цветов, никогда не срочная).
|
||||
|
||||
+12
-1
@@ -20,7 +20,18 @@ tests or touching CI.
|
||||
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
||||
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
||||
invitations section, the stats screen, profile editing, and the export chooser's
|
||||
finished-only visibility + its signed-URL download flow (route-intercepted).
|
||||
finished-only visibility + its signed-URL download flow (route-intercepted). The
|
||||
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
|
||||
end-to-end: it forces the installed-PWA display mode, enters offline through the
|
||||
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates
|
||||
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
|
||||
rack is deterministic and the human can tap out a precomputed opening), asserting the
|
||||
robot's real reply and the IndexedDB replay after a reload. A local game needs a real
|
||||
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
|
||||
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
|
||||
CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
|
||||
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
|
||||
production build.
|
||||
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
||||
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
||||
real self-played 35-move game) must rasterize to a plausible PNG
|
||||
|
||||
@@ -315,6 +315,19 @@ left. A **viewport size change** (e.g. a portrait↔landscape rotation) re-measu
|
||||
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
|
||||
and a long message does not scroll.
|
||||
|
||||
**Per-campaign colours.** A campaign may override the strip's colours (background, text, link). The
|
||||
engine carries the current message's campaign colours to the host, and `AdBanner` resolves them for
|
||||
the **rendered theme** (`lib/bannerColors`: dark ← dark-set ?? all-set, light ← all-set) and applies
|
||||
them as inline CSS variables scoped to `.ad` (`--ad-bg`, `--text-muted`, `--accent`, and a derived
|
||||
`--ad-border`) — so an override never leaks past the strip, and a campaign with no override keeps the
|
||||
neutral tokens. The border is computed from the background in JS (a luminance-aware nudge toward
|
||||
black/white — no CSS `color-mix`, which the old Android WebView floor lacks), matching the admin
|
||||
console's live preview. The resolution re-runs when the theme flips (a `[data-theme]` /
|
||||
`prefers-color-scheme` observer), so switching light↔dark repaints an overridden strip at once. An
|
||||
**urgent** campaign has no client-specific styling — it simply arrives (with its colours) as the only
|
||||
campaign in the feed; the preempt-and-show-everyone behaviour is entirely server-side (ARCHITECTURE
|
||||
§10).
|
||||
|
||||
## Result / status iconography (`lib/result.ts`)
|
||||
|
||||
Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
|
||||
|
||||
@@ -25,12 +25,16 @@ ARG VITE_TELEGRAM_BOT_ID=
|
||||
ARG VITE_TELEGRAM_LINK=
|
||||
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME=
|
||||
ARG VITE_VK_APP_LINK=
|
||||
ARG VITE_VK_APP_ID=
|
||||
ARG VITE_VK_ID_REDIRECT_URL=
|
||||
ARG VITE_GATEWAY_URL=
|
||||
ARG VITE_APP_VERSION=
|
||||
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
|
||||
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_APP_VERSION=$VITE_APP_VERSION
|
||||
|
||||
|
||||
+15
-3
@@ -8,6 +8,10 @@ backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
||||
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
||||
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (unhashed from `ui/public/`,
|
||||
with the `.webmanifest` MIME type registered in-process — the distroless image has no
|
||||
`/etc/mime.types`) and the app-shell `sw.js` (built from `ui/src/sw.ts` by vite-plugin-pwa, which
|
||||
precaches the shell + assets so an installed PWA cold-launches offline).
|
||||
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
||||
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||
in the deployed contour the front caddy owns `/_gm` (see
|
||||
@@ -61,6 +65,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
|
||||
(`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`,
|
||||
`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;
|
||||
@@ -72,9 +82,10 @@ refetch). The social/account/history ops —
|
||||
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
|
||||
`stats.get`, `game.gcg`, and the `notify` live event — go through the identical
|
||||
transcode pattern (`transcode_social.go`). Account linking & merge
|
||||
— `link.email.request/confirm/merge` and `link.telegram.confirm/merge`
|
||||
(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the
|
||||
validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
||||
— `link.email.request/confirm/merge`, `link.telegram.confirm/merge` and
|
||||
`link.vk.confirm/merge` (`transcode_link.go`); the telegram ops validate the **Login
|
||||
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.
|
||||
|
||||
## Configuration
|
||||
@@ -89,6 +100,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_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_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_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) |
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
"scrabble/gateway/internal/session"
|
||||
"scrabble/gateway/internal/transcode"
|
||||
"scrabble/gateway/internal/vkid"
|
||||
"scrabble/pkg/mtls"
|
||||
botlinkv1 "scrabble/pkg/proto/botlink/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)")
|
||||
}
|
||||
|
||||
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{
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
|
||||
@@ -42,6 +42,15 @@ type ProfileResp struct {
|
||||
Email string `json:"email"`
|
||||
TelegramLinked bool `json:"telegram_linked"`
|
||||
VkLinked bool `json:"vk_linked"`
|
||||
// DictVersions is the current dictionary version per game variant, forwarded verbatim
|
||||
// into the Profile payload so an offline-capable client preloads the matching dawg.
|
||||
DictVersions []DictVersion `json:"dict_versions,omitempty"`
|
||||
}
|
||||
|
||||
// DictVersion pairs a game variant's stable label with its current dictionary version.
|
||||
type DictVersion struct {
|
||||
Variant string `json:"variant"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// BannerResp is the advertising-banner block of an eligible viewer's profile: the
|
||||
@@ -53,9 +62,18 @@ type BannerResp struct {
|
||||
|
||||
// BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show
|
||||
// weight and its messages, in display order, already resolved to one language.
|
||||
// The override_* colours are the optional per-campaign palette (empty when the
|
||||
// campaign keeps the neutral theme tokens); they ride through to the client
|
||||
// verbatim, mirroring the backend DTO.
|
||||
type BannerCampaignResp struct {
|
||||
Weight int `json:"weight"`
|
||||
Messages []string `json:"messages"`
|
||||
OverrideBg string `json:"override_bg,omitempty"`
|
||||
OverrideFg string `json:"override_fg,omitempty"`
|
||||
OverrideLink string `json:"override_link,omitempty"`
|
||||
OverrideBgDark string `json:"override_bg_dark,omitempty"`
|
||||
OverrideFgDark string `json:"override_fg_dark,omitempty"`
|
||||
OverrideLinkDark string `json:"override_link_dark,omitempty"`
|
||||
}
|
||||
|
||||
// BannerTimingsResp mirrors the backend's global display timings.
|
||||
@@ -169,6 +187,7 @@ type StateResp struct {
|
||||
BagLen int `json:"bag_len"`
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
WalletBalance int `json:"wallet_balance"`
|
||||
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
||||
}
|
||||
|
||||
@@ -280,10 +299,11 @@ func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp,
|
||||
|
||||
// EmailRequest asks the backend to mail a login code, provisioning the account on
|
||||
// first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new
|
||||
// account's time zone, since the email account is created here, not at login.
|
||||
func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string) error {
|
||||
// account's time zone, since the email account is created here, not at login. pwa marks a
|
||||
// request from an installed PWA, so the backend omits the one-tap confirm link from the email.
|
||||
func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string, pwa bool) error {
|
||||
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
|
||||
map[string]string{"email": email, "browser_tz": browserTz, "language": language}, nil)
|
||||
map[string]any{"email": email, "browser_tz": browserTz, "language": language, "pwa": pwa}, nil)
|
||||
}
|
||||
|
||||
// EmailLogin verifies a login code and mints a session.
|
||||
|
||||
@@ -335,6 +335,24 @@ func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID strin
|
||||
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 {
|
||||
|
||||
@@ -36,6 +36,11 @@ type Config struct {
|
||||
// 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).
|
||||
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
|
||||
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
|
||||
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
|
||||
// validates the result.
|
||||
func Load() (Config, error) {
|
||||
@@ -174,6 +195,11 @@ func Load() (Config, error) {
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||
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,
|
||||
RateLimit: DefaultRateLimit(),
|
||||
Abuse: DefaultAbuse(),
|
||||
|
||||
@@ -32,6 +32,8 @@ type serverMetrics struct {
|
||||
localColdStart metric.Int64Counter
|
||||
localDictLoad metric.Int64Counter
|
||||
localPreview metric.Int64Counter
|
||||
// Clients turned away by the unsupported-engine boot screen (see unsupportedEngineHandler).
|
||||
unsupportedEngine metric.Int64Counter
|
||||
}
|
||||
|
||||
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
|
||||
@@ -44,7 +46,13 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
||||
}
|
||||
h, err := meter.Float64Histogram("edge_request_duration",
|
||||
metric.WithUnit("s"),
|
||||
metric.WithDescription("Seconds to serve one Connect Execute call, by message type and result."))
|
||||
metric.WithDescription("Seconds to serve one Connect Execute call, by message type and result."),
|
||||
// Explicit second-scale buckets. The durations are recorded in seconds, so the SDK's
|
||||
// default millisecond-calibrated boundaries (first boundary 5) would bin every sub-5s
|
||||
// request into one bucket — making histogram_quantile(0.99) interpolate to ~4.95s
|
||||
// regardless of the real latency, which flapped the >1s edge-latency alert. These
|
||||
// boundaries straddle the 1s SLO so the p99 reflects real (mostly sub-second) latency.
|
||||
metric.WithExplicitBucketBoundaries(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10))
|
||||
if err != nil {
|
||||
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("edge_request_duration")
|
||||
}
|
||||
@@ -63,6 +71,8 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
||||
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
|
||||
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
|
||||
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
|
||||
unsupportedEngine: counterOf(meter, "unsupported_engine_total",
|
||||
"Clients that hit the unsupported-engine boot screen (the app cannot run), by reason (no_bigint, no_proxy, boot_error, other) and Chromium major — a deduped beacon from the index.html boot guard; the full user agent is logged, not labelled."),
|
||||
}
|
||||
|
||||
gauge, err := meter.Int64ObservableGauge("active_users",
|
||||
@@ -108,6 +118,16 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
|
||||
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
||||
}
|
||||
|
||||
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
|
||||
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
|
||||
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
|
||||
func (m *serverMetrics) recordUnsupportedEngine(ctx context.Context, reason, chromium string) {
|
||||
m.unsupportedEngine.Add(ctx, 1, metric.WithAttributes(
|
||||
attribute.String("reason", reason),
|
||||
attribute.String("chromium", chromium),
|
||||
))
|
||||
}
|
||||
|
||||
// localEvalReport is the client-reported local move-preview telemetry batch — deltas since
|
||||
// the client's previous report. It backs the adoption dashboard: app cold starts vs cached
|
||||
// dictionaries vs on-device previews.
|
||||
|
||||
@@ -29,6 +29,7 @@ func TestEdgeMetric(t *testing.T) {
|
||||
|
||||
type key struct{ messageType, result string }
|
||||
counts := map[key]uint64{}
|
||||
var bounds []float64
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, md := range sm.Metrics {
|
||||
if md.Name != "edge_request_duration" {
|
||||
@@ -42,6 +43,7 @@ func TestEdgeMetric(t *testing.T) {
|
||||
mt, _ := dp.Attributes.Value(attribute.Key("message_type"))
|
||||
res, _ := dp.Attributes.Value(attribute.Key("result"))
|
||||
counts[key{mt.AsString(), res.AsString()}] += dp.Count
|
||||
bounds = dp.Bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +53,19 @@ func TestEdgeMetric(t *testing.T) {
|
||||
if got := counts[key{"auth.guest", "domain"}]; got != 1 {
|
||||
t.Errorf("edge auth.guest/domain = %d, want 1", got)
|
||||
}
|
||||
// The buckets must be second-scaled. The default (millisecond-calibrated) boundaries have no
|
||||
// boundary between 0 and 5, so every sub-5s request bins into one bucket and p99 interpolates
|
||||
// to ~4.95s, flapping the >1s alert. Require at least one sub-second boundary.
|
||||
subSecond := false
|
||||
for _, b := range bounds {
|
||||
if b > 0 && b < 1 {
|
||||
subSecond = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !subSecond {
|
||||
t.Errorf("edge_request_duration bounds = %v, want sub-second boundaries (seconds-scaled)", bounds)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitedMetric records limiter rejections through a manual reader and
|
||||
@@ -128,3 +143,70 @@ func TestBannedMetric(t *testing.T) {
|
||||
t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnsupportedEngineMetric records unsupported-engine beacons through a manual reader and asserts
|
||||
// unsupported_engine_total splits by reason and Chromium major.
|
||||
func TestUnsupportedEngineMetric(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||
m := newServerMetrics(meter)
|
||||
|
||||
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
|
||||
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
|
||||
m.recordUnsupportedEngine(ctx, "boot_error", "74")
|
||||
|
||||
var rm metricdata.ResourceMetrics
|
||||
if err := reader.Collect(ctx, &rm); err != nil {
|
||||
t.Fatalf("collect: %v", err)
|
||||
}
|
||||
type key struct{ reason, chromium string }
|
||||
counts := map[key]int64{}
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, md := range sm.Metrics {
|
||||
if md.Name != "unsupported_engine_total" {
|
||||
continue
|
||||
}
|
||||
sum, ok := md.Data.(metricdata.Sum[int64])
|
||||
if !ok {
|
||||
t.Fatalf("unsupported_engine_total is not an int64 sum")
|
||||
}
|
||||
for _, dp := range sum.DataPoints {
|
||||
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
|
||||
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
|
||||
counts[key{reason.AsString(), chromium.AsString()}] += dp.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := counts[key{"no_bigint", "66"}]; got != 2 {
|
||||
t.Errorf("unsupported no_bigint/66 = %d, want 2", got)
|
||||
}
|
||||
if got := counts[key{"boot_error", "74"}]; got != 1 {
|
||||
t.Errorf("unsupported boot_error/74 = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeUnsupported checks that a beacon's reason and Chromium are reduced to bounded label
|
||||
// values, so a spoofed beacon cannot explode the metric cardinality.
|
||||
func TestNormalizeUnsupported(t *testing.T) {
|
||||
cases := []struct {
|
||||
reason, chromium string
|
||||
wantReason, wantChromium string
|
||||
}{
|
||||
{"no_bigint", "66", "no_bigint", "66"},
|
||||
{"no_proxy", " 74 ", "no_proxy", "74"},
|
||||
{"boot_error", "105", "boot_error", "105"},
|
||||
{"garbage", "66", "other", "66"},
|
||||
{"", "", "other", "other"},
|
||||
{"no_bigint", "not-a-number", "no_bigint", "other"},
|
||||
{"no_bigint", "9999", "no_bigint", "other"},
|
||||
{"no_bigint", "0", "no_bigint", "other"},
|
||||
{"no_bigint", "-5", "no_bigint", "other"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
gotR, gotC := normalizeUnsupported(c.reason, c.chromium)
|
||||
if gotR != c.wantReason || gotC != c.wantChromium {
|
||||
t.Errorf("normalizeUnsupported(%q,%q) = %q,%q; want %q,%q", c.reason, c.chromium, gotR, gotC, c.wantReason, c.wantChromium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -197,6 +198,9 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
mux.Handle("/dl/", s.exportDownloadHandler())
|
||||
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
||||
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
||||
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
|
||||
// screen (the app cannot run). Unauthenticated — the client never booted — but rate-limited.
|
||||
mux.Handle("/telemetry/unsupported", s.unsupportedEngineHandler())
|
||||
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
|
||||
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
|
||||
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
|
||||
@@ -582,6 +586,76 @@ func clampReport(r *localEvalReport) {
|
||||
clamp(&r.PreviewNetwork)
|
||||
}
|
||||
|
||||
// unsupportedEngineBeacon is the small fire-and-forget report the index.html boot guard sends when
|
||||
// it turns a client away on the unsupported-engine screen (no BigInt/Proxy, or an uncaught boot
|
||||
// error). It is deduped client-side (one per device / app version / reason).
|
||||
type unsupportedEngineBeacon struct {
|
||||
Reason string `json:"reason"`
|
||||
Chromium string `json:"chromium"`
|
||||
Version string `json:"version"`
|
||||
UA string `json:"ua"`
|
||||
}
|
||||
|
||||
// unsupportedEngineHandler folds one unsupported-engine beacon into the edge counter. It is
|
||||
// unauthenticated (the client never booted, so it carries no session) but per-IP rate-limited with
|
||||
// the public limiter and body-capped. reason and the Chromium major are reduced to bounded label
|
||||
// sets (normalizeUnsupported) so a spoofed beacon cannot inflate the metric cardinality; the full
|
||||
// user agent is logged, not labelled. Only POST; the reply is always 204.
|
||||
func (s *Server) unsupportedEngineHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ip := peerIP(r.RemoteAddr, r.Header)
|
||||
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
|
||||
s.noteRateLimited(r.Context(), classPublic, ip, "unsupported")
|
||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
var b unsupportedEngineBeacon
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&b); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
reason, chromium := normalizeUnsupported(b.Reason, b.Chromium)
|
||||
s.metrics.recordUnsupportedEngine(r.Context(), reason, chromium)
|
||||
s.log.Info("unsupported engine",
|
||||
zap.String("reason", reason),
|
||||
zap.String("chromium", chromium),
|
||||
zap.String("app_version", truncate(b.Version, 40)),
|
||||
zap.String("user_agent", truncate(b.UA, 400)),
|
||||
)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeUnsupported reduces a beacon's reason and Chromium fields to bounded label values, so a
|
||||
// spoofed beacon cannot explode the metric cardinality: reason is allow-listed, and Chromium is
|
||||
// parsed as a major version kept only within a plausible range, otherwise "other".
|
||||
func normalizeUnsupported(reason, chromium string) (string, string) {
|
||||
switch reason {
|
||||
case "no_bigint", "no_proxy", "boot_error":
|
||||
// a recognised reason — keep as-is
|
||||
default:
|
||||
reason = "other"
|
||||
}
|
||||
major := "other"
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(chromium)); err == nil && n >= 1 && n <= 199 {
|
||||
major = strconv.Itoa(n)
|
||||
}
|
||||
return reason, major
|
||||
}
|
||||
|
||||
// truncate bounds a logged, client-supplied string to n bytes (a spoofed beacon field is not
|
||||
// trusted to be small).
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// resolve extracts and resolves the Authorization bearer token to an account id
|
||||
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
||||
// or unknown.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package connectsrv_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
|
||||
"scrabble/gateway/internal/config"
|
||||
"scrabble/gateway/internal/connectsrv"
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
)
|
||||
|
||||
// TestUnsupportedEngineHandler drives the /telemetry/unsupported beacon route end to end: a POST
|
||||
// increments unsupported_engine_total with the normalised labels and replies 204; a GET is 405.
|
||||
func TestUnsupportedEngineHandler(t *testing.T) {
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Limiter: ratelimit.New(),
|
||||
RateLimit: config.DefaultRateLimit(),
|
||||
Meter: meter,
|
||||
})
|
||||
srv := httptest.NewServer(edge.HTTPHandler())
|
||||
defer srv.Close()
|
||||
url := srv.URL + "/telemetry/unsupported"
|
||||
|
||||
// A GET is rejected.
|
||||
getResp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
getResp.Body.Close()
|
||||
if getResp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("GET status = %d, want 405", getResp.StatusCode)
|
||||
}
|
||||
|
||||
// A POST is accepted (204) and folded into the counter with normalised labels.
|
||||
body := `{"reason":"no_bigint","chromium":"66","version":"v1.2.3","ua":"Mozilla/5.0 ... Chrome/66"}`
|
||||
resp, err := http.Post(url, "application/json", bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("POST status = %d, want 204", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rm metricdata.ResourceMetrics
|
||||
if err := reader.Collect(context.Background(), &rm); err != nil {
|
||||
t.Fatalf("collect: %v", err)
|
||||
}
|
||||
var total int64
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, md := range sm.Metrics {
|
||||
if md.Name != "unsupported_engine_total" {
|
||||
continue
|
||||
}
|
||||
sum, ok := md.Data.(metricdata.Sum[int64])
|
||||
if !ok {
|
||||
t.Fatalf("unsupported_engine_total is not an int64 sum")
|
||||
}
|
||||
for _, dp := range sum.DataPoints {
|
||||
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
|
||||
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
|
||||
if reason.AsString() != "no_bigint" || chromium.AsString() != "66" {
|
||||
t.Errorf("labels = %s/%s, want no_bigint/66", reason.AsString(), chromium.AsString())
|
||||
}
|
||||
total += dp.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
if total != 1 {
|
||||
t.Errorf("unsupported_engine_total = %d, want 1", total)
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||
banner = encodeBanner(b, *p.Banner)
|
||||
}
|
||||
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
|
||||
dictVersions := encodeDictVersions(b, p.DictVersions)
|
||||
fb.ProfileStart(b)
|
||||
fb.ProfileAddUserId(b, uid)
|
||||
fb.ProfileAddDisplayName(b, name)
|
||||
@@ -111,6 +112,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||
fb.ProfileAddEmail(b, email)
|
||||
fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
|
||||
fb.ProfileAddVkLinked(b, p.VkLinked)
|
||||
if dictVersions != 0 {
|
||||
fb.ProfileAddDictVersions(b, dictVersions)
|
||||
}
|
||||
if p.Banner != nil {
|
||||
fb.ProfileAddBanner(b, banner)
|
||||
}
|
||||
@@ -118,6 +122,48 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeDictVersions builds the Profile's dict_versions vector — one DictVersion table per
|
||||
// variant/version pair — and returns its offset, or 0 when there are none (the field is then
|
||||
// omitted). Every child table and its strings are created before the vector is opened, per the
|
||||
// FlatBuffers rule against nesting a table under one still being built; the caller invokes it
|
||||
// before ProfileStart for the same reason.
|
||||
func encodeDictVersions(b *flatbuffers.Builder, dvs []backendclient.DictVersion) flatbuffers.UOffsetT {
|
||||
if len(dvs) == 0 {
|
||||
return 0
|
||||
}
|
||||
offsets := make([]flatbuffers.UOffsetT, len(dvs))
|
||||
for i, dv := range dvs {
|
||||
variant := b.CreateString(dv.Variant)
|
||||
version := b.CreateString(dv.Version)
|
||||
fb.DictVersionStart(b)
|
||||
fb.DictVersionAddVariant(b, variant)
|
||||
fb.DictVersionAddVersion(b, version)
|
||||
offsets[i] = fb.DictVersionEnd(b)
|
||||
}
|
||||
fb.ProfileStartDictVersionsVector(b, len(offsets))
|
||||
for i := len(offsets) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(offsets[i])
|
||||
}
|
||||
return b.EndVector(len(offsets))
|
||||
}
|
||||
|
||||
// optString creates a FlatBuffers string for a non-empty value, or 0 to omit the
|
||||
// optional field (the client then reads it as absent).
|
||||
func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
return b.CreateString(s)
|
||||
}
|
||||
|
||||
// addOptString adds an optional string slot only when it was created (a 0 offset
|
||||
// leaves the field absent). add is the generated BannerCampaignAdd* setter.
|
||||
func addOptString(b *flatbuffers.Builder, add func(*flatbuffers.Builder, flatbuffers.UOffsetT), off flatbuffers.UOffsetT) {
|
||||
if off != 0 {
|
||||
add(b, off)
|
||||
}
|
||||
}
|
||||
|
||||
// encodeBanner builds a BannerInfo table from the resolved banner block and
|
||||
// returns its offset. It is bottom-up: each campaign's messages vector and table
|
||||
// are built first, then the campaigns vector, then the BannerInfo table. The
|
||||
@@ -134,9 +180,23 @@ func encodeBanner(b *flatbuffers.Builder, banner backendclient.BannerResp) flatb
|
||||
b.PrependUOffsetT(msgOffsets[j])
|
||||
}
|
||||
msgs := b.EndVector(len(msgOffsets))
|
||||
// The optional colour strings must be created before the table starts; an
|
||||
// empty colour is omitted (offset 0), so the client reads it as absent.
|
||||
obg := optString(b, c.OverrideBg)
|
||||
ofg := optString(b, c.OverrideFg)
|
||||
olink := optString(b, c.OverrideLink)
|
||||
obgD := optString(b, c.OverrideBgDark)
|
||||
ofgD := optString(b, c.OverrideFgDark)
|
||||
olinkD := optString(b, c.OverrideLinkDark)
|
||||
fb.BannerCampaignStart(b)
|
||||
fb.BannerCampaignAddWeight(b, int32(c.Weight))
|
||||
fb.BannerCampaignAddMessages(b, msgs)
|
||||
addOptString(b, fb.BannerCampaignAddOverrideBg, obg)
|
||||
addOptString(b, fb.BannerCampaignAddOverrideFg, ofg)
|
||||
addOptString(b, fb.BannerCampaignAddOverrideLink, olink)
|
||||
addOptString(b, fb.BannerCampaignAddOverrideBgDark, obgD)
|
||||
addOptString(b, fb.BannerCampaignAddOverrideFgDark, ofgD)
|
||||
addOptString(b, fb.BannerCampaignAddOverrideLinkDark, olinkD)
|
||||
campOffsets[i] = fb.BannerCampaignEnd(b)
|
||||
}
|
||||
fb.BannerInfoStartCampaignsVector(b, len(campOffsets))
|
||||
@@ -245,6 +305,7 @@ func toWireState(s backendclient.StateResp) wire.StateView {
|
||||
BagLen: s.BagLen,
|
||||
HintsRemaining: s.HintsRemaining,
|
||||
WalletBalance: s.WalletBalance,
|
||||
HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
|
||||
Alphabet: alphabet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/connector"
|
||||
"scrabble/gateway/internal/vkauth"
|
||||
"scrabble/gateway/internal/vkid"
|
||||
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.
|
||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||
op, ok := r.ops[messageType]
|
||||
@@ -172,6 +182,9 @@ func DomainCode(err error) (string, bool) {
|
||||
if errors.Is(err, vkauth.ErrInvalid) {
|
||||
return "invalid_vk_params", true
|
||||
}
|
||||
if errors.Is(err, vkid.ErrInvalid) {
|
||||
return "invalid_vk_id", true
|
||||
}
|
||||
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
||||
return "invalid_login_widget", true
|
||||
}
|
||||
@@ -240,7 +253,7 @@ func authGuestHandler(backend *backendclient.Client) Handler {
|
||||
func authEmailRequestHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsEmailRequestRequest(req.Payload, 0)
|
||||
if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language())); err != nil {
|
||||
if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language()), in.Pwa()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeAck(true), nil
|
||||
|
||||
@@ -61,6 +61,54 @@ func TestProfileGetEncodesBanner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileGetEncodesBannerColors verifies the gateway carries a campaign's
|
||||
// optional colour overrides into the FlatBuffers payload, and leaves an absent
|
||||
// colour (or an absent set) empty so the client falls back to the theme tokens.
|
||||
func TestProfileGetEncodesBannerColors(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` +
|
||||
`"banner":{"campaigns":[` +
|
||||
`{"weight":1,"messages":["promo-en"],` +
|
||||
`"override_bg":"#aa0000","override_fg":"#ffffff","override_link":"#ffdd00",` +
|
||||
`"override_bg_dark":"#330000","override_fg_dark":"#eeeeee","override_link_dark":"#ffcc00"},` +
|
||||
`{"weight":1,"messages":["plain-en"]}],` +
|
||||
`"timings":{"hold_ms":60000,"edge_pause_ms":5000,"scroll_px_per_sec":40,` +
|
||||
`"fade_out_ms":1000,"gap_ms":250,"fade_in_ms":1000}}}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil)
|
||||
op, _ := reg.Lookup(transcode.MsgProfileGet)
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
banner := fb.GetRootAsProfile(payload, 0).Banner(nil)
|
||||
if banner == nil {
|
||||
t.Fatal("profile carries no banner block")
|
||||
}
|
||||
var c fb.BannerCampaign
|
||||
banner.Campaigns(&c, 0)
|
||||
if got := string(c.OverrideBg()); got != "#aa0000" {
|
||||
t.Errorf("override_bg = %q, want #aa0000", got)
|
||||
}
|
||||
if got := string(c.OverrideLink()); got != "#ffdd00" {
|
||||
t.Errorf("override_link = %q, want #ffdd00", got)
|
||||
}
|
||||
if got := string(c.OverrideBgDark()); got != "#330000" {
|
||||
t.Errorf("override_bg_dark = %q, want #330000", got)
|
||||
}
|
||||
if got := string(c.OverrideLinkDark()); got != "#ffcc00" {
|
||||
t.Errorf("override_link_dark = %q, want #ffcc00", got)
|
||||
}
|
||||
// The second campaign has no override: every colour field must be absent (nil).
|
||||
banner.Campaigns(&c, 1)
|
||||
if c.OverrideBg() != nil || c.OverrideFg() != nil || c.OverrideLink() != nil ||
|
||||
c.OverrideBgDark() != nil || c.OverrideFgDark() != nil || c.OverrideLinkDark() != nil {
|
||||
t.Error("plain campaign unexpectedly carries a colour override")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileGetNoBanner verifies an ineligible viewer's profile carries no
|
||||
// banner block (the backend omits it).
|
||||
func TestProfileGetNoBanner(t *testing.T) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/vkid"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,8 @@ const (
|
||||
MsgLinkEmailMerge = "link.email.merge"
|
||||
MsgLinkTelegram = "link.telegram.confirm"
|
||||
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"
|
||||
@@ -154,3 +157,44 @@ func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, me
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
||||
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3}`))
|
||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3,"hint_unlock_left_seconds":1200}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
st := fb.GetRootAsStateView(payload, 0)
|
||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 {
|
||||
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance())
|
||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 {
|
||||
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance(), st.HintUnlockLeftSeconds())
|
||||
}
|
||||
game := st.Game(nil)
|
||||
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ package webui
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
@@ -35,13 +36,25 @@ func distFS() fs.FS {
|
||||
return sub
|
||||
}
|
||||
|
||||
// Handler serves the embedded UI. An existing file is served directly (hash-named assets get
|
||||
// an immutable cache); every other path falls back to indexName (the SPA shell) so a
|
||||
// client-side deep link still loads. When stripPrefix is non-empty it is removed from the
|
||||
// request path before lookup, so the same build serves under a sub-path (e.g. "/app/" or
|
||||
// "/telegram/").
|
||||
// init registers the MIME type for .webmanifest, which Go's built-in table lacks and the
|
||||
// distroless runtime image has no /etc/mime.types to supply. Without it the PWA Web App Manifest
|
||||
// served under /app/ would be content-sniffed to text/plain, which some browsers reject.
|
||||
func init() {
|
||||
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
||||
}
|
||||
|
||||
// Handler serves the compile-time embedded UI build over the public edge — the game SPA under
|
||||
// /app/, /telegram/ and /vk/. It delegates to handlerFor over the embedded dist/.
|
||||
func Handler(stripPrefix, indexName string) http.Handler {
|
||||
content := distFS()
|
||||
return handlerFor(distFS(), stripPrefix, indexName)
|
||||
}
|
||||
|
||||
// handlerFor serves content as the UI: an existing file is served directly (hash-named assets get
|
||||
// an immutable cache); every other path falls back to indexName (the SPA shell) so a client-side
|
||||
// deep link still loads. When stripPrefix is non-empty it is removed from the request path before
|
||||
// lookup, so the same build serves under a sub-path (e.g. "/app/" or "/telegram/"). Split from
|
||||
// Handler so it can be exercised over an in-memory fs.FS in tests.
|
||||
func handlerFor(content fs.FS, stripPrefix, indexName string) http.Handler {
|
||||
files := http.FileServer(http.FS(content))
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
@@ -58,6 +71,11 @@ func Handler(stripPrefix, indexName string) http.Handler {
|
||||
// app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download.
|
||||
if strings.HasPrefix(name, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else if name == "sw.js" {
|
||||
// The service worker must be revalidated on every load so a new deploy's worker (and its
|
||||
// fresh precache manifest) is picked up promptly; a cached sw.js would strand clients on
|
||||
// the old build. The worker's network-first navigation then serves the fresh shell online.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
// get drives the handler with a GET for the given path and returns the response.
|
||||
@@ -55,3 +56,60 @@ func TestAppMountServesShellStripsPrefixAndCachesAssets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testFS mirrors what Vite emits into dist/: the SPA shell, the PWA manifest + service worker at
|
||||
// the root (copied from ui/public), and a hash-named asset. The compile-time embedded dist/ holds
|
||||
// only a placeholder shell, so the manifest/sw.js serving is exercised over this in-memory FS.
|
||||
func testFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"index.html": {Data: []byte("<!doctype html><title>shell</title>")},
|
||||
"manifest.webmanifest": {Data: []byte(`{"name":"Эрудит"}`)},
|
||||
"sw.js": {Data: []byte("self.addEventListener('fetch', () => {});")},
|
||||
"assets/app-abc123.js": {Data: []byte("export const x = 1;")},
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerServesManifestWithManifestType: the PWA manifest is served with the manifest MIME
|
||||
// type (registered in init, since the distroless image has no /etc/mime.types), not sniffed to
|
||||
// text/plain, which some browsers reject.
|
||||
func TestHandlerServesManifestWithManifestType(t *testing.T) {
|
||||
h := handlerFor(testFS(), "/app/", "index.html")
|
||||
resp := get(t, h, "/app/manifest.webmanifest")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/manifest+json") {
|
||||
t.Errorf("Content-Type = %q, want application/manifest+json", ct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerServesServiceWorkerAsJavaScript: sw.js is served as JavaScript from the root, so its
|
||||
// registration (scope /app/) succeeds.
|
||||
func TestHandlerServesServiceWorkerAsJavaScript(t *testing.T) {
|
||||
h := handlerFor(testFS(), "/app/", "index.html")
|
||||
resp := get(t, h, "/app/sw.js")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
|
||||
t.Errorf("Content-Type = %q, want text/javascript", ct)
|
||||
}
|
||||
// Revalidated on every load so a new deploy's worker (and its fresh precache) is picked up.
|
||||
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
|
||||
t.Errorf("sw.js Cache-Control = %q, want no-cache", cc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerFallsBackToShellForUnknownManifestPath guards the trap: a manifest/sw path NOT emitted
|
||||
// into dist/ falls back to the SPA shell as text/html, on which SW registration would fail. The two
|
||||
// tests above pin that the real files are served instead of the shell.
|
||||
func TestHandlerFallsBackToShellForUnknownManifestPath(t *testing.T) {
|
||||
h := handlerFor(testFS(), "/app/", "index.html")
|
||||
resp := get(t, h, "/app/not-emitted.webmanifest")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html (SPA shell fallback)", ct)
|
||||
}
|
||||
}
|
||||
|
||||
+45
-1
@@ -138,6 +138,11 @@ table EmailRequestRequest {
|
||||
email:string;
|
||||
browser_tz:string;
|
||||
language:string;
|
||||
// Set when the request originates from an installed PWA (standalone display mode): the
|
||||
// backend then omits the one-tap confirm link from the login email so the code is typed in
|
||||
// the same window, avoiding a link that opens in a separate browser (a different storage
|
||||
// context) where the minted session could not reach the PWA.
|
||||
pwa:bool;
|
||||
}
|
||||
|
||||
// EmailLoginRequest logs in to the account owning email (provisioned at the
|
||||
@@ -180,10 +185,20 @@ table Ack {
|
||||
// BannerCampaign is one campaign in the rotation feed: a GCD-reduced show weight
|
||||
// (the client runs a smooth weighted round-robin over campaigns by this weight)
|
||||
// and its messages in display order, each already resolved to the viewer's bot
|
||||
// language (the stored en/ru pair is picked server-side).
|
||||
// language (the stored en/ru pair is picked server-side). The override_* colours
|
||||
// are an optional per-campaign palette: override_bg/fg/link paint the strip on
|
||||
// every theme, and the *_dark trio further overrides the dark theme (the client
|
||||
// resolves dark ← dark ?? all ?? token, light ← all ?? token). Empty means the
|
||||
// campaign keeps the neutral theme tokens (all added trailing — backward-compatible).
|
||||
table BannerCampaign {
|
||||
weight:int;
|
||||
messages:[string];
|
||||
override_bg:string;
|
||||
override_fg:string;
|
||||
override_link:string;
|
||||
override_bg_dark:string;
|
||||
override_fg_dark:string;
|
||||
override_link_dark:string;
|
||||
}
|
||||
|
||||
// BannerInfo is the advertising-banner block of an eligible viewer's profile: the
|
||||
@@ -208,6 +223,13 @@ table BannerInfo {
|
||||
// suppresses out-of-app platform push, leaving only the in-app live stream. banner
|
||||
// carries the advertising-banner block for an eligible viewer, absent otherwise
|
||||
// (all added trailing — backward-compatible).
|
||||
// DictVersion is one variant's current dictionary version, carried on the profile so an offline
|
||||
// client learns it from an existing cold-start request (no extra call for a rarely-used feature).
|
||||
table DictVersion {
|
||||
variant:string;
|
||||
version:string;
|
||||
}
|
||||
|
||||
table Profile {
|
||||
user_id:string;
|
||||
display_name:string;
|
||||
@@ -230,6 +252,10 @@ table Profile {
|
||||
email:string;
|
||||
telegram_linked:bool;
|
||||
vk_linked:bool;
|
||||
// dict_versions carries each variant's current dictionary version so an offline client can
|
||||
// preload the right dictionary and pin a new local game without a separate request (added
|
||||
// trailing — backward-compatible).
|
||||
dict_versions:[DictVersion];
|
||||
}
|
||||
|
||||
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
||||
@@ -290,6 +316,13 @@ table StateView {
|
||||
// the wallet is a single global figure the client keeps live across games (added trailing —
|
||||
// backward-compatible).
|
||||
wallet_balance:int;
|
||||
// hint_unlock_left_seconds is, for a vs_ai game, how many seconds until the idle hint unlocks
|
||||
// (the robot's last move plus the idle window, computed from the SERVER clock online / the device
|
||||
// clock offline, capped at the window and floored at 0); 0 for a human's first move (no robot move
|
||||
// yet) or a non-vs_ai game. The client anchors a MONOTONIC countdown (performance.now()) to it, so a
|
||||
// client clock change cannot skew it — see lib/hints. Seconds granularity is enough; sent trailing
|
||||
// (additive, backward-compatible).
|
||||
hint_unlock_left_seconds:int;
|
||||
}
|
||||
|
||||
// GameActionRequest carries just a game id (pass / resign / hint / history).
|
||||
@@ -501,6 +534,17 @@ table LinkTelegramRequest {
|
||||
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 {
|
||||
|
||||
@@ -70,8 +70,56 @@ func (rcv *BannerCampaign) MessagesLength() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *BannerCampaign) OverrideBg() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *BannerCampaign) OverrideFg() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *BannerCampaign) OverrideLink() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *BannerCampaign) OverrideBgDark() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *BannerCampaign) OverrideFgDark() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *BannerCampaign) OverrideLinkDark() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func BannerCampaignStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(2)
|
||||
builder.StartObject(8)
|
||||
}
|
||||
func BannerCampaignAddWeight(builder *flatbuffers.Builder, weight int32) {
|
||||
builder.PrependInt32Slot(0, weight, 0)
|
||||
@@ -82,6 +130,24 @@ func BannerCampaignAddMessages(builder *flatbuffers.Builder, messages flatbuffer
|
||||
func BannerCampaignStartMessagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
}
|
||||
func BannerCampaignAddOverrideBg(builder *flatbuffers.Builder, overrideBg flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(overrideBg), 0)
|
||||
}
|
||||
func BannerCampaignAddOverrideFg(builder *flatbuffers.Builder, overrideFg flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(overrideFg), 0)
|
||||
}
|
||||
func BannerCampaignAddOverrideLink(builder *flatbuffers.Builder, overrideLink flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(overrideLink), 0)
|
||||
}
|
||||
func BannerCampaignAddOverrideBgDark(builder *flatbuffers.Builder, overrideBgDark flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(overrideBgDark), 0)
|
||||
}
|
||||
func BannerCampaignAddOverrideFgDark(builder *flatbuffers.Builder, overrideFgDark flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(overrideFgDark), 0)
|
||||
}
|
||||
func BannerCampaignAddOverrideLinkDark(builder *flatbuffers.Builder, overrideLinkDark flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(overrideLinkDark), 0)
|
||||
}
|
||||
func BannerCampaignEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package scrabblefb
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type DictVersion struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &DictVersion{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &DictVersion{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *DictVersion) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *DictVersion) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *DictVersion) Variant() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *DictVersion) Version() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DictVersionStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(2)
|
||||
}
|
||||
func DictVersionAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0)
|
||||
}
|
||||
func DictVersionAddVersion(builder *flatbuffers.Builder, version flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(version), 0)
|
||||
}
|
||||
func DictVersionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -65,8 +65,20 @@ func (rcv *EmailRequestRequest) Language() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *EmailRequestRequest) Pwa() bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (rcv *EmailRequestRequest) MutatePwa(n bool) bool {
|
||||
return rcv._tab.MutateBoolSlot(10, n)
|
||||
}
|
||||
|
||||
func EmailRequestRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(3)
|
||||
builder.StartObject(4)
|
||||
}
|
||||
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
|
||||
@@ -77,6 +89,9 @@ func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz fla
|
||||
func EmailRequestRequestAddLanguage(builder *flatbuffers.Builder, language flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(language), 0)
|
||||
}
|
||||
func EmailRequestRequestAddPwa(builder *flatbuffers.Builder, pwa bool) {
|
||||
builder.PrependBoolSlot(3, pwa, false)
|
||||
}
|
||||
func EmailRequestRequestEnd(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()
|
||||
}
|
||||
@@ -211,8 +211,28 @@ func (rcv *Profile) MutateVkLinked(n bool) bool {
|
||||
return rcv._tab.MutateBoolSlot(34, n)
|
||||
}
|
||||
|
||||
func (rcv *Profile) DictVersions(obj *DictVersion, j int) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(36))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Vector(o)
|
||||
x += flatbuffers.UOffsetT(j) * 4
|
||||
x = rcv._tab.Indirect(x)
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (rcv *Profile) DictVersionsLength() int {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(36))
|
||||
if o != 0 {
|
||||
return rcv._tab.VectorLen(o)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func ProfileStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(16)
|
||||
builder.StartObject(17)
|
||||
}
|
||||
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
||||
@@ -265,6 +285,12 @@ func ProfileAddTelegramLinked(builder *flatbuffers.Builder, telegramLinked bool)
|
||||
func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) {
|
||||
builder.PrependBoolSlot(15, vkLinked, false)
|
||||
}
|
||||
func ProfileAddDictVersions(builder *flatbuffers.Builder, dictVersions flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(16, flatbuffers.UOffsetT(dictVersions), 0)
|
||||
}
|
||||
func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
}
|
||||
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -156,8 +156,20 @@ func (rcv *StateView) MutateWalletBalance(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(16, n)
|
||||
}
|
||||
|
||||
func (rcv *StateView) HintUnlockLeftSeconds() int32 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *StateView) MutateHintUnlockLeftSeconds(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(18, n)
|
||||
}
|
||||
|
||||
func StateViewStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(7)
|
||||
builder.StartObject(8)
|
||||
}
|
||||
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0)
|
||||
@@ -186,6 +198,9 @@ func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) fl
|
||||
func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
|
||||
builder.PrependInt32Slot(6, walletBalance, 0)
|
||||
}
|
||||
func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) {
|
||||
builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0)
|
||||
}
|
||||
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ type StateView struct {
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
WalletBalance int
|
||||
HintUnlockLeftSeconds int
|
||||
Alphabet []AlphabetEntry
|
||||
}
|
||||
|
||||
@@ -256,6 +257,7 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT {
|
||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
||||
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
|
||||
if hasAlphabet {
|
||||
fb.StateViewAddAlphabet(b, alphabet)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed
|
||||
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the
|
||||
// Settings offline toggle shows.
|
||||
async function forceStandalone(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const orig = window.matchMedia.bind(window);
|
||||
window.matchMedia = (q: string) =>
|
||||
(q.includes('display-mode: standalone')
|
||||
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
|
||||
: orig(q)) as MediaQueryList;
|
||||
});
|
||||
}
|
||||
|
||||
async function enterLobby(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /guest|гост/i }).first().click();
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
}
|
||||
|
||||
async function goOffline(page: Page): Promise<void> {
|
||||
await page.locator('button.tab').nth(2).click(); // Settings
|
||||
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
}
|
||||
|
||||
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
|
||||
// It first waits for the dots to be empty, so a call made right after a previous entry does not lose
|
||||
// digits during the 250 ms verdict pause (the 4th digit → pause → clear/advance).
|
||||
async function typePin(page: Page, digits: string): Promise<void> {
|
||||
await expect(page.locator('.pad .dot.on')).toHaveCount(0);
|
||||
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
|
||||
}
|
||||
|
||||
test.describe('offline hotseat (pass-and-play)', () => {
|
||||
test('create with a locked seat, unlock, host-skip, terminate from the lobby', async ({ page }) => {
|
||||
await forceStandalone(page);
|
||||
await page.goto('/');
|
||||
await enterLobby(page);
|
||||
await goOffline(page);
|
||||
|
||||
// A known seed: the bag deals a full rack deterministically, AND the seeded seating shuffle keeps
|
||||
// the roster order (Ann first) so the locked-seat assertions below are stable. (Seat order is
|
||||
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
|
||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
|
||||
|
||||
// New game -> the offline mode selector now offers "with friends" (hotseat).
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
||||
|
||||
// The player rows are disabled until the mandatory host (master) PIN is set.
|
||||
await expect(page.locator('.pname').first()).toBeDisabled();
|
||||
|
||||
// Set the host PIN (enter + confirm), then decline taking a seat.
|
||||
await page.locator('.hostpin .plink').click();
|
||||
await typePin(page, '9999');
|
||||
await typePin(page, '9999');
|
||||
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
|
||||
|
||||
// Pick the English variant (the plaques mirror Quick Match; "Scrabble" is the Latin English name).
|
||||
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
||||
|
||||
// Two players: Ann (PIN-locked) and Bob (open).
|
||||
await page.locator('.pname').nth(0).fill('Ann');
|
||||
await page.locator('.pname').nth(1).fill('Bob');
|
||||
|
||||
// Row-delete: a 3rd player's kebab asks the host PIN, which ARMS a ❌ (not a silent delete);
|
||||
// tapping the ❌ removes the row.
|
||||
await page.getByRole('button', { name: /Add player|Добавить/i }).click();
|
||||
await expect(page.locator('.prow')).toHaveCount(3);
|
||||
await page.locator('.prow').nth(2).locator('.pkebab').click();
|
||||
await typePin(page, '9999');
|
||||
const rowDel = page.locator('.prow').nth(2).locator('.prow-del');
|
||||
await expect(rowDel).toBeVisible();
|
||||
await rowDel.click();
|
||||
await expect(page.locator('.prow')).toHaveCount(2);
|
||||
|
||||
// Lock Ann's seat with a PIN.
|
||||
await page.locator('.prow').nth(0).locator('.plink').click();
|
||||
await typePin(page, '1234');
|
||||
await typePin(page, '1234');
|
||||
|
||||
await page.locator('button.invite').click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
|
||||
// Ann is to move and PIN-locked: the board is visible but her rack is withheld behind Unlock.
|
||||
await expect(page.locator('.unlock')).toBeVisible();
|
||||
await expect(page.locator('.rack .tile')).toHaveCount(0);
|
||||
|
||||
// A wrong PIN keeps it locked; the right PIN reveals the full rack.
|
||||
await page.locator('.unlock').click();
|
||||
await typePin(page, '0000');
|
||||
await expect(page.locator('.pad')).toBeVisible(); // still open after a wrong PIN
|
||||
await typePin(page, '1234');
|
||||
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
||||
|
||||
// Opening the history reveals NO social controls on the seat plaques (a local game is
|
||||
// account-less), and the Dictionary entry (the comms button) is kept for the active game.
|
||||
await page.locator('.scoreboard').click();
|
||||
await expect(page.locator('.fico')).toHaveCount(0);
|
||||
await expect(page.locator('.chat-ico')).toBeVisible();
|
||||
await page.locator('.scoreboard').click();
|
||||
|
||||
// Host override: 🔐 -> master PIN -> skip the current turn -> confirm. The turn passes to Bob,
|
||||
// whose seat is open, so his rack shows without a lock.
|
||||
await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click();
|
||||
await typePin(page, '9999');
|
||||
await page.getByRole('button', { name: /Skip|Пропустить/ }).click();
|
||||
await page.getByRole('button', { name: /^(OK|ОК)$/ }).click();
|
||||
await expect(page.locator('.unlock')).toHaveCount(0);
|
||||
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
||||
|
||||
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||
await expect(page.locator('.rowwrap')).toHaveCount(1);
|
||||
await page.locator('.kebab').first().click();
|
||||
const del = page.locator('.rowwrap.revealed .del');
|
||||
await expect(del).toBeVisible();
|
||||
await del.click();
|
||||
await typePin(page, '9999');
|
||||
await expect(page.locator('.rowwrap')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from './fixtures';
|
||||
|
||||
// The install CTA (components/InstallApp.svelte) is platform-adaptive: a one-tap button where the
|
||||
// browser offers a beforeinstallprompt (Chromium), a manual instructions modal on iOS Safari, and
|
||||
// nothing elsewhere / once installed / inside a Mini App. The native OS install dialog is
|
||||
// browser-level and not drivable from Playwright, so these assert the CTA's presence and branch.
|
||||
|
||||
const IPHONE_SAFARI =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
|
||||
test('web: a captured install prompt shows the one-tap CTA and clicking it prompts', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// The web login screen is shown (an ordinary browser tab, not a Mini App).
|
||||
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
|
||||
|
||||
// Hidden until the browser offers installation (Playwright never fires beforeinstallprompt).
|
||||
await expect(page.getByRole('button', { name: /Install the app/i })).toHaveCount(0);
|
||||
|
||||
// Simulate Chromium offering the install: dispatch a beforeinstallprompt the app can capture.
|
||||
await page.evaluate(() => {
|
||||
const e = new Event('beforeinstallprompt') as Event & {
|
||||
prompt?: () => Promise<void>;
|
||||
userChoice?: Promise<unknown>;
|
||||
};
|
||||
(window as unknown as { __promptCalled: boolean }).__promptCalled = false;
|
||||
e.prompt = () => {
|
||||
(window as unknown as { __promptCalled: boolean }).__promptCalled = true;
|
||||
return Promise.resolve();
|
||||
};
|
||||
e.userChoice = Promise.resolve({ outcome: 'accepted', platform: '' });
|
||||
window.dispatchEvent(e);
|
||||
});
|
||||
|
||||
const cta = page.getByRole('button', { name: /Install the app/i });
|
||||
await expect(cta).toBeVisible();
|
||||
await cta.click();
|
||||
// Clicking calls the captured prompt (the native dialog itself is not observable here).
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as unknown as { __promptCalled: boolean }).__promptCalled))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test.describe('iOS Safari', () => {
|
||||
test.use({ userAgent: IPHONE_SAFARI });
|
||||
|
||||
test('shows manual Add-to-Home-Screen instructions (no programmatic install)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
|
||||
|
||||
// On iOS Safari the CTA shows at once (no beforeinstallprompt is ever fired there).
|
||||
const cta = page.getByRole('button', { name: /Install the app/i });
|
||||
await expect(cta).toBeVisible();
|
||||
await cta.click();
|
||||
|
||||
// It opens the app's own instructions modal (not a native / Telegram popup).
|
||||
await expect(page.getByRole('heading', { name: 'Add to Home Screen' })).toBeVisible();
|
||||
await expect(page.getByText(/Tap the Share button/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -46,3 +46,15 @@ test('the landing is a normal scrolling document (the SPA document-pin does not
|
||||
expect(state.shell).toBe(false);
|
||||
expect(state.bodyPosition).not.toBe('fixed');
|
||||
});
|
||||
|
||||
// The web-version entry (/app/) is always present, alongside the build-var-gated Telegram/VK ones
|
||||
// (absent here, since e2e sets no VITE_* vars), each with a caption.
|
||||
test('the landing shows a web-version entry linking /app/, with a caption', async ({ page }) => {
|
||||
await page.goto('/landing.html');
|
||||
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); // Russian by default
|
||||
|
||||
const web = page.getByRole('link', { name: 'Играть в браузере' });
|
||||
await expect(web).toBeVisible();
|
||||
expect(await web.getAttribute('href')).toContain('/app/');
|
||||
await expect(page.getByText('Веб-версия')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// The offline mode is gated to an installed standalone PWA with a confirmed email. The mock account
|
||||
// has an email; force the standalone display mode so the Settings offline toggle is offered. Only the
|
||||
// `(display-mode: standalone)` query is overridden — theme/reduce-motion queries pass through.
|
||||
async function forceStandalone(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const orig = window.matchMedia.bind(window);
|
||||
window.matchMedia = (q: string) =>
|
||||
(q.includes('display-mode: standalone')
|
||||
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
|
||||
: orig(q)) as MediaQueryList;
|
||||
});
|
||||
}
|
||||
|
||||
// After a load, land in the lobby. The mock cold-starts on the login screen (no seeded session), so
|
||||
// click through as guest. Waiting for the button (rather than a point-in-time count()) is what makes
|
||||
// this deterministic: a count() sampled during the pre-login splash frame returned 0, skipped the
|
||||
// click, and then hung — or latched a transient lobby tab-bar — instead of opening the real lobby.
|
||||
async function enterLobby(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /guest|гост/i }).first().click();
|
||||
// The lobby tab bar has three tabs in a fixed order: New (0), Stats (1), Settings (2). nth() is
|
||||
// robust to locale, emoji variation selectors and the coachmark anchors (which the first-run
|
||||
// onboarding strips after it completes).
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
}
|
||||
|
||||
// Pick a rack tile by its glyph and drop it on a board square (the rack of the pinned-seed game has
|
||||
// all-distinct letters, so the glyph is unambiguous).
|
||||
async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> {
|
||||
await page.locator('.rack .tile', { hasText: glyph }).first().click();
|
||||
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
|
||||
}
|
||||
|
||||
test.describe('offline mode', () => {
|
||||
test('enter via the toggle, play a local vs_ai game, persist across a reload', async ({ page }) => {
|
||||
await forceStandalone(page);
|
||||
await page.goto('/');
|
||||
await enterLobby(page);
|
||||
|
||||
// Online: a seeded online game (vs Ann) is listed.
|
||||
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||
|
||||
// Enter offline through the real Settings toggle (its readiness check fetches every enabled
|
||||
// variant's dawg, served from /e2edict/ by the mock) — the header turns blue with the chip.
|
||||
await page.locator('button.tab').nth(2).click();
|
||||
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Back in the (now offline) lobby: the online games are hidden and the Stats tab is disabled.
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
|
||||
await expect(page.locator('button.tab').nth(1)).toBeDisabled();
|
||||
|
||||
// Create a device-local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO).
|
||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
// The English variant's display name is "Scrabble" (Latin) in both locales — the Russian
|
||||
// variants are "Скрэббл"/"Эрудит"/"Erudite", so this uniquely selects the English game.
|
||||
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
||||
await page.locator('button.invite').click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
|
||||
// The human's first move is not idle-gated: the hint is available at once (no 🔒 lock badge).
|
||||
await expect(page.locator('.lock')).toHaveCount(0);
|
||||
|
||||
// The human plays WAY horizontally across the centre (7,5)-(7,7).
|
||||
await placeTile(page, 'W', 7, 5);
|
||||
await placeTile(page, 'A', 7, 6);
|
||||
await placeTile(page, 'Y', 7, 7);
|
||||
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
|
||||
await page.locator('.make').click();
|
||||
|
||||
// The play commits and the local robot replies with a real move, so the board carries more than
|
||||
// WAY's three tiles.
|
||||
await expect(async () => {
|
||||
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
|
||||
}).toPass({ timeout: 15000 });
|
||||
const filled = await page.locator('[data-cell].filled').count();
|
||||
|
||||
// Now it is the human's turn again after the robot moved: the idle hint gate arms, so the hint
|
||||
// button shows the 🔒 lock (it lifts after 30 idle minutes on a monotonic clock — not waited here).
|
||||
await expect(page.locator('.lock')).toBeVisible();
|
||||
|
||||
// Reload: the hash router restores the /game/<id> route and the local game replays from
|
||||
// IndexedDB with every committed tile intact.
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
await expect(page.locator('[data-cell].filled')).toHaveCount(filled);
|
||||
// The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload —
|
||||
// the wait was not reset by relaunching.
|
||||
await expect(page.locator('.lock')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from './fixtures';
|
||||
|
||||
// The hash router must update its reactive `route` rune synchronously inside navigate(), not defer it
|
||||
// to the asynchronous `hashchange` event. Bootstrap flips `app.ready` in the same tick right after
|
||||
// `navigate('/login')` on an unauthenticated cold start; a route that trailed the hash for one frame
|
||||
// let App.svelte render the stale route (the empty-hash lobby) under the new login screen — a visible
|
||||
// lobby-shell flash plus a doomed games.list. It also made the offline e2e flaky: enterLobby could
|
||||
// latch that transient lobby tab-bar instead of clicking through the login screen.
|
||||
|
||||
test('navigate updates the reactive route synchronously (no hashchange lag)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// The bundle has loaded and installed the mock __router seam once the login screen is up.
|
||||
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
|
||||
|
||||
// Read the route in the SAME microtask as the navigate: the fix makes it the new route at once;
|
||||
// the pre-fix code returned the previous route (it waited for the async hashchange).
|
||||
const routeAfterNavigate = await page.evaluate(() => {
|
||||
const r = (window as unknown as { __router: { navigate(p: string): void; route(): string } }).__router;
|
||||
r.navigate('/settings');
|
||||
return r.route();
|
||||
});
|
||||
expect(routeAfterNavigate).toBe('settings');
|
||||
|
||||
// The hash was written too, so a reload/back still resolves the same route.
|
||||
expect(new URL(page.url()).hash).toBe('#/settings');
|
||||
});
|
||||
@@ -327,6 +327,36 @@ test('change email: a free address replaces the current one', async ({ page }) =
|
||||
await expect(page.getByText('you@example.com')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// The add-email confirmation can complete out of band: the recipient taps the one-tap link in the
|
||||
// email, which confirms in another browser/session. A backgrounded Mini App misses the live
|
||||
// 'profile' event (the stream is single-shot, no replay), so the open code form falls back to
|
||||
// polling the profile until the address lands. The mock attaches the email WITHOUT emitting an
|
||||
// event, so only the poll can surface it.
|
||||
test('add email: an out-of-band confirmation surfaces on the open form via polling', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await openProfile(page);
|
||||
|
||||
// Drop the seeded email so the sign-in section offers the add-email flow.
|
||||
await page.evaluate(() => (window as unknown as { __mock: { clearEmail(): void } }).__mock.clearEmail());
|
||||
const emailInput = page.locator('.accounts input[type="email"]');
|
||||
await expect(emailInput).toBeVisible();
|
||||
|
||||
await emailInput.fill('linked@example.com');
|
||||
await page.getByRole('button', { name: 'Send code' }).click();
|
||||
await expect(page.locator('.accounts .codein')).toBeVisible();
|
||||
|
||||
// Confirmed elsewhere via the one-tap link, with no live event delivered: only the poll surfaces it.
|
||||
await page.evaluate(() =>
|
||||
(
|
||||
window as unknown as { __mock: { confirmEmailOutOfBand(email: string): void } }
|
||||
).__mock.confirmEmailOutOfBand('linked@example.com'),
|
||||
);
|
||||
|
||||
// The confirmed address surfaces as the email row, and the code form is gone.
|
||||
await expect(page.locator('.acctrow').filter({ hasText: 'linked@example.com' })).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.accounts .codein')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('link then unlink Telegram from the sign-in methods', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await openProfile(page);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Playwright's WebKit has no real soft keyboard, so emulate iOS's visual-viewport behaviour: replace
|
||||
// window.visualViewport with a controllable fake BEFORE the app boots. The app's syncViewport
|
||||
// (app.svelte.ts) reads visualViewport.height/offsetTop and mirrors them into --vvh / --vv-top, which
|
||||
// position the pinned app-shell (app.css html.app-shell body). Driving the fake exercises the exact
|
||||
// code path the real keyboard triggers on iOS — where the layout viewport does NOT shrink and the
|
||||
// visual viewport instead offsets down toward the focused field.
|
||||
async function installFakeViewport(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const fake = new EventTarget() as EventTarget & { height: number; offsetTop: number; width: number };
|
||||
fake.height = window.innerHeight;
|
||||
fake.offsetTop = 0;
|
||||
fake.width = window.innerWidth;
|
||||
Object.defineProperty(window, 'visualViewport', { configurable: true, value: fake });
|
||||
(window as unknown as { __vv: typeof fake }).__vv = fake;
|
||||
});
|
||||
}
|
||||
|
||||
async function setViewport(page: Page, height: number, offsetTop: number): Promise<void> {
|
||||
await page.evaluate(
|
||||
([h, t]) => {
|
||||
const vv = (window as unknown as { __vv: { height: number; offsetTop: number; dispatchEvent(e: Event): boolean } }).__vv;
|
||||
vv.height = h;
|
||||
vv.offsetTop = t;
|
||||
vv.dispatchEvent(new Event('resize'));
|
||||
vv.dispatchEvent(new Event('scroll'));
|
||||
},
|
||||
[height, offsetTop],
|
||||
);
|
||||
}
|
||||
|
||||
async function shell(page: Page): Promise<{ vvh: string; top: string; bodyTop: string }> {
|
||||
return page.evaluate(() => ({
|
||||
vvh: getComputedStyle(document.documentElement).getPropertyValue('--vvh').trim(),
|
||||
top: getComputedStyle(document.documentElement).getPropertyValue('--vv-top').trim(),
|
||||
bodyTop: getComputedStyle(document.body).top,
|
||||
}));
|
||||
}
|
||||
|
||||
test.describe('visual-viewport shell (soft-keyboard alignment)', () => {
|
||||
test('the pinned shell follows the visual viewport height AND offset', async ({ page }) => {
|
||||
await installFakeViewport(page);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('html.app-shell')).toBeAttached();
|
||||
const innerH = await page.evaluate(() => window.innerHeight);
|
||||
|
||||
// Keyboard closed: full height, no offset.
|
||||
await setViewport(page, innerH, 0);
|
||||
let s = await shell(page);
|
||||
expect(s.top).toBe('0px');
|
||||
expect(s.bodyTop).toBe('0px');
|
||||
|
||||
// Keyboard open + iOS offset: the visual viewport shrinks AND offsets down. The pinned shell must
|
||||
// follow BOTH — the body's top must equal the offset (not stay at 0), else the top-anchored
|
||||
// content shows empty space below (the iOS bug this fixes).
|
||||
await setViewport(page, innerH - 300, 180);
|
||||
s = await shell(page);
|
||||
expect(s.vvh).toBe(`${innerH - 300}px`);
|
||||
expect(s.top).toBe('180px');
|
||||
expect(s.bodyTop).toBe('180px');
|
||||
|
||||
// Keyboard closed again: the offset reverts to 0 — no stale shift left behind.
|
||||
await setViewport(page, innerH, 0);
|
||||
s = await shell(page);
|
||||
expect(s.top).toBe('0px');
|
||||
expect(s.bodyTop).toBe('0px');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user