Compare commits
38 Commits
60faa4f064
...
v1.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 45957bdcd6 | |||
| fcedadcb5b | |||
| 2feb638329 | |||
| 4dfedd02a3 | |||
| 829e29a726 | |||
| 44117e906c | |||
| c90331b189 | |||
| 399508f2f0 | |||
| 0dfecc2af7 | |||
| 446ea2ac45 | |||
| 01a9249002 | |||
| 7dcd62fdd7 | |||
| d67e582c03 | |||
| 75fe07865a | |||
| 597e200f37 | |||
| 3ef18d33b6 | |||
| c8601c0115 | |||
| a0021d1994 | |||
| 4b4dcab9b6 | |||
| 7f85362288 | |||
| 72e9b600b0 | |||
| 0eefbfd6a4 | |||
| c680e695d3 | |||
| 2c465c01d2 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
+61
-23
@@ -26,12 +26,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [development]
|
branches: [development]
|
||||||
|
|
||||||
# The dictionary release the test suite validates against — the current
|
# The dictionary release. One Gitea variable is the single source of truth: the
|
||||||
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
|
# test suite validates against it here (inherited by the unit/integration jobs) and
|
||||||
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
# both contours' deploy jobs seed a fresh volume with the same value. A release bump
|
||||||
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
# is one edit (the variable). See deploy/README.md.
|
||||||
env:
|
env:
|
||||||
DICT_VERSION: v1.3.1
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||||
@@ -329,16 +329,25 @@ jobs:
|
|||||||
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
||||||
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
||||||
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
|
# One VK Mini App serves every contour -> unprefixed secret.
|
||||||
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
|
# VK ID web login (browser VK-identity linking): the VK ID "Web" app's protected key
|
||||||
|
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
||||||
|
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
|
||||||
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
|
||||||
|
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
|
||||||
|
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
|
||||||
# Signs the finished-game export download URLs (backend + compose interpolation).
|
# Signs the finished-game export download URLs (backend + compose interpolation).
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
||||||
# Transactional email via the shared Selectel relay. Empty host leaves the
|
# Transactional email via the shared Selectel relay: one account for every
|
||||||
# backend on the log mailer (email disabled) but the contour still boots.
|
# contour -> unprefixed host/port/tls/user/pass. Empty host leaves the backend
|
||||||
SMTP_RELAY_USER: ${{ secrets.TEST_SMTP_RELAY_USER }}
|
# on the log mailer (email disabled) but the contour still boots.
|
||||||
SMTP_RELAY_PASS: ${{ secrets.TEST_SMTP_RELAY_PASS }}
|
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||||
SMTP_RELAY_HOST: ${{ vars.TEST_SMTP_RELAY_HOST }}
|
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||||
SMTP_RELAY_PORT: ${{ vars.TEST_SMTP_RELAY_PORT }}
|
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
|
||||||
SMTP_RELAY_TLS: ${{ vars.TEST_SMTP_RELAY_TLS }}
|
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
||||||
|
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
|
||||||
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
|
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
|
||||||
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
|
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
|
||||||
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
|
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
|
||||||
@@ -347,15 +356,15 @@ jobs:
|
|||||||
ADMIN_EMAIL: ${{ vars.TEST_ADMIN_EMAIL }}
|
ADMIN_EMAIL: ${{ vars.TEST_ADMIN_EMAIL }}
|
||||||
SMTP_RELAY_SERVICE_FROM: ${{ vars.TEST_SMTP_RELAY_SERVICE_FROM }}
|
SMTP_RELAY_SERVICE_FROM: ${{ vars.TEST_SMTP_RELAY_SERVICE_FROM }}
|
||||||
SERVICE_EMAIL: ${{ vars.TEST_SERVICE_EMAIL }}
|
SERVICE_EMAIL: ${{ vars.TEST_SERVICE_EMAIL }}
|
||||||
GRAFANA_SMTP_PORT: ${{ vars.TEST_GRAFANA_SMTP_PORT }}
|
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
|
||||||
GF_SMTP_ENABLED: ${{ vars.TEST_GF_SMTP_ENABLED }}
|
GF_SMTP_ENABLED: ${{ vars.TEST_GF_SMTP_ENABLED }}
|
||||||
# Canonical public origin for links in the email (this contour's URL);
|
# Canonical public origin for links in the email (this contour's URL);
|
||||||
# required by the backend whenever SMTP_RELAY_HOST is set.
|
# required by the backend whenever SMTP_RELAY_HOST is set.
|
||||||
PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }}
|
PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
|
# TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL and VITE_VK_ID_REDIRECT_URL are derived
|
||||||
|
# from PUBLIC_BASE_URL in the run step below, not stored as their own variables.
|
||||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
|
||||||
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
|
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||||
@@ -368,12 +377,16 @@ jobs:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
VITE_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }}
|
# VK Mini App landing link + VK ID "Web" app id: one value each serves every
|
||||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
# contour -> unprefixed. VITE_VK_APP_ID also feeds the gateway (GATEWAY_VK_ID_APP_ID);
|
||||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
|
||||||
|
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
|
||||||
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
|
# VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the
|
||||||
|
# compose ":-" empty default. Other unset vars likewise fall to their defaults.
|
||||||
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||||
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
|
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
|
||||||
DICT_VERSION: ${{ vars.TEST_DICT_VERSION }}
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
|
||||||
run: |
|
run: |
|
||||||
# Seed the config files to a stable host path. The runner checks out into
|
# Seed the config files to a stable host path. The runner checks out into
|
||||||
@@ -386,6 +399,20 @@ jobs:
|
|||||||
mkdir -p "$conf"
|
mkdir -p "$conf"
|
||||||
cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
|
cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
|
||||||
export SCRABBLE_CONFIG_DIR="$conf"
|
export SCRABBLE_CONFIG_DIR="$conf"
|
||||||
|
# Maintenance page for the redeploy window, mirroring prod-deploy.sh so the SPA
|
||||||
|
# overlay is exercised on the test contour too (not only prod). Raised just before
|
||||||
|
# the recreate and lowered once caddy is back (below); the trap clears it if the
|
||||||
|
# step fails so the contour never sticks in maintenance (and the reseed above wipes a
|
||||||
|
# stale flag anyway). The caddy-routed probes run in the NEXT step, after it is lowered.
|
||||||
|
maint_flag="$conf/caddy/on"
|
||||||
|
trap 'rm -f "$maint_flag"' EXIT
|
||||||
|
# Derive the public URLs from the one canonical origin instead of storing each as
|
||||||
|
# its own variable (paths are structural SPA routes / the Caddy /_gm sub-path).
|
||||||
|
# Exported before build so the VK ID redirect is baked into the SPA.
|
||||||
|
base="${PUBLIC_BASE_URL%/}"
|
||||||
|
export TELEGRAM_MINIAPP_URL="$base/telegram/"
|
||||||
|
export GRAFANA_ROOT_URL="$base/_gm/grafana/"
|
||||||
|
export VITE_VK_ID_REDIRECT_URL="$base/app/"
|
||||||
# Grafana's SMTP from_address must be a BARE address (it rejects the "Name" <addr>
|
# 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
|
# 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
|
# bad value crash-loops Grafana. Split the display-format SERVICE From into a bare
|
||||||
@@ -410,12 +437,23 @@ jobs:
|
|||||||
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
|
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
|
||||||
# main host omits both. Without the profile they would not start here.
|
# main host omits both. Without the profile they would not start here.
|
||||||
docker compose --ansi never --profile telegram-local build --progress plain
|
docker compose --ansi never --profile telegram-local build --progress plain
|
||||||
|
# Raise the maintenance page, THEN bring caddy onto the reseeded config mount so it
|
||||||
|
# actually carries the flag: the running caddy sits on the stale pre-reseed mount (the
|
||||||
|
# dir was rm'd + recreated — see the force-recreate note below), so a flag written to
|
||||||
|
# the new dir is invisible until caddy is recreated. With the fresh caddy up, an open
|
||||||
|
# SPA sees the 503 marker + overlay for the whole recreate window, not a bare reconnect.
|
||||||
|
: > "$maint_flag"
|
||||||
|
docker compose --ansi never up -d --force-recreate --no-deps caddy
|
||||||
docker compose --ansi never --profile telegram-local up -d --remove-orphans
|
docker compose --ansi never --profile telegram-local up -d --remove-orphans
|
||||||
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
|
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
|
||||||
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
|
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
|
||||||
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
|
# changed Grafana dashboard is ignored — force-recreate them to pick up the fresh
|
||||||
# pick up the fresh config.
|
# config. (Caddy was already recreated above so it would carry the maintenance flag.)
|
||||||
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
|
docker compose --ansi never up -d --force-recreate --no-deps otelcol prometheus tempo grafana
|
||||||
|
# Lower the maintenance page: services are back. An open SPA's poll now gets through
|
||||||
|
# (once the gateway finishes booting) and reloads into the fresh client; the caddy
|
||||||
|
# probes in the next step see 200. The EXIT trap is a backstop if we failed earlier.
|
||||||
|
rm -f "$maint_flag"
|
||||||
|
|
||||||
- name: Probe the landing, gateway and backend
|
- name: Probe the landing, gateway and backend
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -40,12 +40,22 @@ jobs:
|
|||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
VITE_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }}
|
# VK Mini App link + VK ID "Web" app id: one value each serves every contour.
|
||||||
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
|
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
|
||||||
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
|
# VITE_GATEWAY_URL omitted: the SPA is served same-origin (compose ":-" default).
|
||||||
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# `docker compose build` interpolates the WHOLE compose file, so every :?-guarded
|
||||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
# runtime var must be present at build even though it is not a build-arg — incl. the
|
||||||
|
# backend's EXPORT_SIGN_KEY (added with the finished-game export after v1.7.0, which is
|
||||||
|
# why the first v1.8.0 build tripped on it). POSTGRES_PASSWORD/GM_BASICAUTH_HASH above
|
||||||
|
# are here for the same reason; DICT_VERSION + the derived Mini App URL cover the rest.
|
||||||
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
|
# PUBLIC_BASE_URL drives the derived VK ID redirect (baked into the SPA) and the
|
||||||
|
# Mini App URL; both are computed in the build step, not stored variables.
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -59,6 +69,11 @@ jobs:
|
|||||||
working-directory: deploy
|
working-directory: deploy
|
||||||
run: |
|
run: |
|
||||||
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
|
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
|
||||||
|
# Derive the public URLs from the one canonical origin: the VK ID redirect is a
|
||||||
|
# build-arg baked into the SPA, and the Mini App URL satisfies the (profiled-out)
|
||||||
|
# bot service's compose ":?" guard during parse. See deploy/write-prod-env.sh.
|
||||||
|
base="${PUBLIC_BASE_URL%/}"
|
||||||
|
export VITE_VK_ID_REDIRECT_URL="$base/app/" TELEGRAM_MINIAPP_URL="$base/telegram/"
|
||||||
# The main-stack images via compose (reuses the build args, incl. VERSION);
|
# The main-stack images via compose (reuses the build args, incl. VERSION);
|
||||||
# the bot separately, since it is profiled out of the prod compose.
|
# the bot separately, since it is profiled out of the prod compose.
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
|
||||||
@@ -83,15 +98,24 @@ jobs:
|
|||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
|
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
|
||||||
|
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
|
||||||
|
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
|
||||||
|
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
||||||
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
# Transactional email via the shared Selectel relay (confirm-codes).
|
# Transactional email via the shared Selectel relay (confirm-codes): one account for
|
||||||
SMTP_RELAY_USER: ${{ secrets.PROD_SMTP_RELAY_USER }}
|
# every contour -> unprefixed host/port/tls/user/pass.
|
||||||
SMTP_RELAY_PASS: ${{ secrets.PROD_SMTP_RELAY_PASS }}
|
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||||
SMTP_RELAY_HOST: ${{ vars.PROD_SMTP_RELAY_HOST }}
|
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||||
SMTP_RELAY_PORT: ${{ vars.PROD_SMTP_RELAY_PORT }}
|
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
|
||||||
SMTP_RELAY_TLS: ${{ vars.PROD_SMTP_RELAY_TLS }}
|
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
||||||
|
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
|
||||||
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
|
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
|
||||||
# Operator alerts: backend admin emails + Grafana infra alerts (distinct senders +
|
# Operator alerts: backend admin emails + Grafana infra alerts (distinct senders +
|
||||||
# recipients; Grafana uses the relay's STARTTLS host:port).
|
# recipients; Grafana uses the relay's STARTTLS host:port).
|
||||||
@@ -99,20 +123,20 @@ jobs:
|
|||||||
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
|
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
|
||||||
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
|
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
|
||||||
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
|
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
|
||||||
GRAFANA_SMTP_PORT: ${{ vars.PROD_GRAFANA_SMTP_PORT }}
|
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
|
||||||
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
|
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
|
||||||
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
||||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
|
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
||||||
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# TELEGRAM_MINIAPP_URL and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in
|
||||||
|
# deploy/write-prod-env.sh, not stored variables.
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -140,51 +164,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-main
|
mkdir -p stage/certs-main
|
||||||
# Grafana needs a BARE from-address (it rejects the "Name" <addr> form the backend
|
# Shared with prod-rollback so the two paths render an identical runtime env.
|
||||||
# accepts, and validates it even when disabled); split the display-format here.
|
APP_VERSION="$TAG" bash deploy/write-prod-env.sh stage/env.sh
|
||||||
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 > 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 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_ABUSE_BAN_ENABLED='true'
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
||||||
@@ -223,7 +204,8 @@ jobs:
|
|||||||
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
||||||
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# PUBLIC_BASE_URL drives the derived Mini App URL in deploy/write-prod-bot-env.sh.
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
||||||
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
|
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||||
@@ -240,20 +222,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-bot
|
mkdir -p stage/certs-bot
|
||||||
cat > stage/env.bot.sh <<EOF
|
# Shared with prod-rollback so the two paths render an identical bot env.
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TAG" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
|
||||||
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TAG'
|
|
||||||
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
|
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
|
||||||
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
|
|
||||||
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
|
|
||||||
export TELEGRAM_SUPPORT_CHAT_ID='$TELEGRAM_SUPPORT_CHAT_ID'
|
|
||||||
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
|
|
||||||
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
|
|
||||||
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
|
|
||||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
|
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
||||||
|
|||||||
@@ -51,13 +51,32 @@ jobs:
|
|||||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
|
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
|
||||||
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
|
# Full runtime env — parity with prod-deploy's deploy-main so a rollback re-renders
|
||||||
|
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
|
||||||
|
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
|
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||||
|
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||||
|
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
|
||||||
|
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
||||||
|
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
|
||||||
|
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
|
||||||
|
SMTP_RELAY_ADMIN_FROM: ${{ vars.PROD_SMTP_RELAY_ADMIN_FROM }}
|
||||||
|
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
|
||||||
|
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
|
||||||
|
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
|
||||||
|
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
|
||||||
|
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
|
||||||
INPUT_TARGET: ${{ inputs.target_version }}
|
INPUT_TARGET: ${{ inputs.target_version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -89,24 +108,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-main
|
mkdir -p stage/certs-main
|
||||||
cat > stage/env.sh <<EOF
|
# Same writer as prod-deploy's deploy-main -> the rollback re-renders the FULL
|
||||||
export REGISTRY='$REGISTRY'
|
# runtime env (not a subset), so email / VK login / Grafana alerts survive it.
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
APP_VERSION="$TARGET" bash deploy/write-prod-env.sh stage/env.sh
|
||||||
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
|
|
||||||
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
|
|
||||||
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
|
|
||||||
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
|
|
||||||
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
|
|
||||||
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
|
|
||||||
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
|
|
||||||
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
|
|
||||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
|
||||||
export DICT_VERSION='$DICT_VERSION'
|
|
||||||
export APP_VERSION='$TARGET'
|
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
|
||||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
|
||||||
@@ -147,9 +151,11 @@ jobs:
|
|||||||
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
|
||||||
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
|
||||||
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
# PUBLIC_BASE_URL drives the derived Mini App URL; SUPPORT_CHAT_ID for parity with deploy.
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
||||||
|
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||||
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
|
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
|
||||||
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||||
steps:
|
steps:
|
||||||
@@ -163,19 +169,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p stage/certs-bot
|
mkdir -p stage/certs-bot
|
||||||
cat > stage/env.bot.sh <<EOF
|
# Same writer as prod-deploy's deploy-bot (parity; includes TELEGRAM_SUPPORT_CHAT_ID).
|
||||||
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
|
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TARGET" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
|
||||||
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TARGET'
|
|
||||||
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
|
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
|
||||||
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
|
|
||||||
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
|
|
||||||
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
|
|
||||||
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
|
|
||||||
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
|
|
||||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
|
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
|
||||||
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ import (
|
|||||||
const RetentionTTL = 2 * 365 * 24 * time.Hour
|
const RetentionTTL = 2 * 365 * 24 * time.Hour
|
||||||
|
|
||||||
// Reasons recorded on a retained_identities row: what detached the credential from its
|
// 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
|
// account (unlink / email change / account deletion here; an account merge that drops a
|
||||||
// the legal dossier (which email/vk/tg was linked, and when) even as the identity frees
|
// same-kind colliding identity writes reason "merge" from the accountmerge package). The
|
||||||
// for reuse. See docs/ARCHITECTURE.md §9.1.
|
// 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 (
|
const (
|
||||||
retainUnlink = "unlink"
|
retainUnlink = "unlink"
|
||||||
retainChange = "change"
|
retainChange = "change"
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import (
|
|||||||
// without taking a dependency on the game package.
|
// without taking a dependency on the game package.
|
||||||
const statusActive = "active"
|
const statusActive = "active"
|
||||||
|
|
||||||
|
// retainReasonMerge is the retained_identities.reason for a credential dropped by a merge
|
||||||
|
// collision (both accounts held the same kind). It mirrors the account package's retain
|
||||||
|
// reasons, kept local to avoid importing that package's unexported constants.
|
||||||
|
const retainReasonMerge = "merge"
|
||||||
|
|
||||||
// Friendship statuses, highest precedence first, mirroring internal/social.
|
// Friendship statuses, highest precedence first, mirroring internal/social.
|
||||||
const (
|
const (
|
||||||
friendAccepted = "accepted"
|
friendAccepted = "accepted"
|
||||||
@@ -75,6 +80,9 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
|
|||||||
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := dedupeIdentities(ctx, tx, primary, secondary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
|
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
|
||||||
return fmt.Errorf("accountmerge: identities: %w", err)
|
return fmt.Errorf("accountmerge: identities: %w", err)
|
||||||
}
|
}
|
||||||
@@ -300,6 +308,77 @@ func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col pos
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dedupeIdentities resolves a same-kind identity collision before the blanket identity
|
||||||
|
// reassign: when both accounts already hold an identity of the same kind (e.g. each has a
|
||||||
|
// confirmed email — reachable when two email-bearing accounts merge), the primary keeps
|
||||||
|
// its own and the secondary's is journaled to retained_identities (reason=merge) and
|
||||||
|
// removed. Without this the blanket reassign would leave the survivor with two identities
|
||||||
|
// of one kind (there is no per-account-kind unique on identities), which the profile and
|
||||||
|
// the retention dossier both treat as singular. Non-colliding identities are untouched and
|
||||||
|
// move with the blanket reassign.
|
||||||
|
func dedupeIdentities(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||||
|
var prows []model.Identities
|
||||||
|
if err := postgres.SELECT(table.Identities.Kind).
|
||||||
|
FROM(table.Identities).
|
||||||
|
WHERE(table.Identities.AccountID.EQ(postgres.UUID(primary))).
|
||||||
|
QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return fmt.Errorf("accountmerge: primary identity kinds: %w", err)
|
||||||
|
}
|
||||||
|
occupied := make(map[string]struct{}, len(prows))
|
||||||
|
for _, r := range prows {
|
||||||
|
occupied[r.Kind] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(occupied) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var srows []model.Identities
|
||||||
|
if err := postgres.SELECT(
|
||||||
|
table.Identities.Kind, table.Identities.ExternalID,
|
||||||
|
table.Identities.Confirmed, table.Identities.CreatedAt,
|
||||||
|
).FROM(table.Identities).
|
||||||
|
WHERE(table.Identities.AccountID.EQ(postgres.UUID(secondary))).
|
||||||
|
QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return fmt.Errorf("accountmerge: secondary identities: %w", err)
|
||||||
|
}
|
||||||
|
for _, s := range srows {
|
||||||
|
if _, dup := occupied[s.Kind]; !dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := retainMergedIdentity(ctx, tx, secondary, s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
del := table.Identities.DELETE().WHERE(
|
||||||
|
table.Identities.AccountID.EQ(postgres.UUID(secondary)).
|
||||||
|
AND(table.Identities.Kind.EQ(postgres.String(s.Kind))).
|
||||||
|
AND(table.Identities.ExternalID.EQ(postgres.String(s.ExternalID))),
|
||||||
|
)
|
||||||
|
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountmerge: drop colliding %s identity: %w", s.Kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// retainMergedIdentity appends a retained_identities row for a secondary identity dropped
|
||||||
|
// by a merge collision (reason=merge), preserving it in the legal dossier. It mirrors
|
||||||
|
// account.retainIdentityTx, which is unexported; detached_at falls to the column default.
|
||||||
|
func retainMergedIdentity(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, id model.Identities) error {
|
||||||
|
rid, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("accountmerge: new retained id: %w", err)
|
||||||
|
}
|
||||||
|
ins := table.RetainedIdentities.INSERT(
|
||||||
|
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
|
||||||
|
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
|
||||||
|
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
|
||||||
|
table.RetainedIdentities.Reason,
|
||||||
|
).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainReasonMerge)
|
||||||
|
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountmerge: retain merged %s identity: %w", id.Kind, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// friendRank ranks a friendship status for dedupe precedence (higher wins).
|
// friendRank ranks a friendship status for dedupe precedence (higher wins).
|
||||||
func friendRank(status string) int {
|
func friendRank(status string) int {
|
||||||
switch status {
|
switch status {
|
||||||
|
|||||||
@@ -251,6 +251,44 @@ func TestAccountMergeFinishedSharedGameKept(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAccountMergeDedupesEmail keeps the primary's email when both accounts have one and
|
||||||
|
// journals the secondary's to the dossier (reason=merge), so the survivor never ends up
|
||||||
|
// with two email identities.
|
||||||
|
func TestAccountMergeDedupesEmail(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
merger := accountmerge.NewMerger(testDB)
|
||||||
|
|
||||||
|
primary := provisionAccount(t)
|
||||||
|
secondary := provisionAccount(t)
|
||||||
|
primaryEmail := "keep-" + uuid.NewString() + "@example.com"
|
||||||
|
secondaryEmail := "absorb-" + uuid.NewString() + "@example.com"
|
||||||
|
bindEmailIdentity(t, primary, primaryEmail)
|
||||||
|
bindEmailIdentity(t, secondary, secondaryEmail)
|
||||||
|
|
||||||
|
if err := merger.Merge(ctx, primary, secondary); err != nil {
|
||||||
|
t.Fatalf("merge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The primary keeps its own email; the secondary's is gone from the live identities.
|
||||||
|
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, primaryEmail); !ok || owner != primary {
|
||||||
|
t.Errorf("primary email owner = %s ok=%v, want primary %s", owner, ok, primary)
|
||||||
|
}
|
||||||
|
if _, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, secondaryEmail); ok {
|
||||||
|
t.Error("the secondary's email must be removed (no duplicate email on the survivor)")
|
||||||
|
}
|
||||||
|
// The absorbed email stays in the legal dossier, tagged reason=merge.
|
||||||
|
var reason string
|
||||||
|
if err := testDB.QueryRowContext(ctx,
|
||||||
|
`SELECT reason FROM backend.retained_identities WHERE account_id=$1 AND kind='email' AND external_id=$2`,
|
||||||
|
secondary, secondaryEmail).Scan(&reason); err != nil {
|
||||||
|
t.Fatalf("retained email row: %v", err)
|
||||||
|
}
|
||||||
|
if reason != "merge" {
|
||||||
|
t.Errorf("retained reason = %q, want merge", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
|
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
|
||||||
func TestAccountLinkFreeEmail(t *testing.T) {
|
func TestAccountLinkFreeEmail(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -355,3 +393,64 @@ func TestAccountLinkGuestInversion(t *testing.T) {
|
|||||||
t.Errorf("email owner = %s, want durable", owner)
|
t.Errorf("email owner = %s, want durable", owner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
|
||||||
|
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
|
||||||
|
func TestAccountLinkFreeVK(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
links := newLinkService(&capturingMailer{})
|
||||||
|
|
||||||
|
guest := provisionGuest(t)
|
||||||
|
vkID := "vk-" + uuid.NewString()
|
||||||
|
res, err := links.ConfirmVK(ctx, guest, vkID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("confirm vk: %v", err)
|
||||||
|
}
|
||||||
|
if !res.Linked || res.MergeRequired {
|
||||||
|
t.Fatalf("confirm = %+v, want linked", res)
|
||||||
|
}
|
||||||
|
if acc, _ := store.GetByID(ctx, guest); acc.IsGuest {
|
||||||
|
t.Error("guest flag should clear once VK is linked")
|
||||||
|
}
|
||||||
|
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); !ok || owner != guest {
|
||||||
|
t.Errorf("vk owner = %s, want the promoted guest %s", owner, guest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAccountLinkVKMergeIntoCaller merges the account owning a VK identity into the
|
||||||
|
// current durable account: ConfirmVK previews the merge, MergeVK folds it, the caller
|
||||||
|
// stays primary and keeps its session, and the VK identity repoints to the caller.
|
||||||
|
func TestAccountLinkVKMergeIntoCaller(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
links := newLinkService(&capturingMailer{})
|
||||||
|
|
||||||
|
caller := provisionAccount(t)
|
||||||
|
other := provisionAccount(t)
|
||||||
|
vkID := "vk-" + uuid.NewString()
|
||||||
|
if err := store.AttachIdentity(ctx, other, account.KindVK, vkID, true); err != nil {
|
||||||
|
t.Fatalf("seed vk on other: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm, err := links.ConfirmVK(ctx, caller, vkID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("confirm vk: %v", err)
|
||||||
|
}
|
||||||
|
if !confirm.MergeRequired || confirm.SecondaryID != other {
|
||||||
|
t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
|
||||||
|
}
|
||||||
|
merge, err := links.MergeVK(ctx, caller, vkID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("merge vk: %v", err)
|
||||||
|
}
|
||||||
|
if merge.PrimaryID != caller || merge.SwitchedToken != "" {
|
||||||
|
t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
|
||||||
|
}
|
||||||
|
if mergedInto(t, other) != caller {
|
||||||
|
t.Error("other should be tombstoned into caller")
|
||||||
|
}
|
||||||
|
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); owner != caller {
|
||||||
|
t.Errorf("vk owner = %s, want caller after merge", owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,6 +133,53 @@ func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, extern
|
|||||||
return s.accounts.ClearGuest(ctx, callerID)
|
return s.accounts.ClearGuest(ctx, callerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfirmVK attaches a gateway-validated VK identity to the caller (Linked) or
|
||||||
|
// reports that it belongs to another account (MergeRequired). The gateway has already
|
||||||
|
// completed the VK ID code exchange, so externalID is the trusted vk user id.
|
||||||
|
func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID string) (ConfirmResult, error) {
|
||||||
|
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
|
||||||
|
if err != nil {
|
||||||
|
return ConfirmResult{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
if err := s.attachVK(ctx, callerID, externalID); err != nil {
|
||||||
|
return ConfirmResult{}, err
|
||||||
|
}
|
||||||
|
return ConfirmResult{Linked: true}, nil
|
||||||
|
}
|
||||||
|
if owner == callerID {
|
||||||
|
return ConfirmResult{Linked: true}, nil
|
||||||
|
}
|
||||||
|
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
|
||||||
|
// (subject to the guest-primary rule).
|
||||||
|
func (s *Service) MergeVK(ctx context.Context, callerID uuid.UUID, externalID string) (MergeResult, error) {
|
||||||
|
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
|
||||||
|
if err != nil {
|
||||||
|
return MergeResult{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
if err := s.attachVK(ctx, callerID, externalID); err != nil {
|
||||||
|
return MergeResult{}, err
|
||||||
|
}
|
||||||
|
return MergeResult{PrimaryID: callerID}, nil
|
||||||
|
}
|
||||||
|
if owner == callerID {
|
||||||
|
return MergeResult{PrimaryID: callerID}, nil
|
||||||
|
}
|
||||||
|
return s.merge(ctx, callerID, owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachVK links the identity to the caller and promotes a guest.
|
||||||
|
func (s *Service) attachVK(ctx context.Context, callerID uuid.UUID, externalID string) error {
|
||||||
|
if err := s.accounts.AttachIdentity(ctx, callerID, account.KindVK, externalID, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.accounts.ClearGuest(ctx, callerID)
|
||||||
|
}
|
||||||
|
|
||||||
// merge decides the primary (the caller, unless it is a guest and the other is
|
// merge decides the primary (the caller, unless it is a guest and the other is
|
||||||
// durable), runs the data merge, retires the secondary's sessions and mints a new
|
// durable), runs the data merge, retires the secondary's sessions and mints a new
|
||||||
// session when the active account switches.
|
// session when the active account switches.
|
||||||
|
|||||||
@@ -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])));
|
||||||
@@ -74,6 +74,8 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/link/email/merge", s.handleLinkEmailMerge)
|
u.POST("/link/email/merge", s.handleLinkEmailMerge)
|
||||||
u.POST("/link/telegram", s.handleLinkTelegram)
|
u.POST("/link/telegram", s.handleLinkTelegram)
|
||||||
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
|
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
|
||||||
|
u.POST("/link/vk", s.handleLinkVK)
|
||||||
|
u.POST("/link/vk/merge", s.handleLinkVKMerge)
|
||||||
u.POST("/link/unlink", s.handleUnlink)
|
u.POST("/link/unlink", s.handleUnlink)
|
||||||
// Change the account's confirmed email: mail a code to the new address, then
|
// 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
|
// atomically switch on confirm (a new address owned by another account is
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ type linkTelegramBody struct {
|
|||||||
ExternalID string `json:"external_id"`
|
ExternalID string `json:"external_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// linkVKBody carries a gateway-validated VK identity (the trusted vk user id the
|
||||||
|
// gateway resolved from the VK ID code exchange).
|
||||||
|
type linkVKBody struct {
|
||||||
|
ExternalID string `json:"external_id"`
|
||||||
|
}
|
||||||
|
|
||||||
// linkResultResponse is the unified result of a confirm or merge step. Status is
|
// linkResultResponse is the unified result of a confirm or merge step. Status is
|
||||||
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
|
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
|
||||||
// account — the secondary_* fields summarise it for the irreversible confirmation),
|
// account — the secondary_* fields summarise it for the irreversible confirmation),
|
||||||
@@ -233,6 +239,48 @@ func (s *Server) handleLinkTelegramMerge(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleLinkVK attaches a gateway-validated VK identity to the caller or reports a
|
||||||
|
// required merge.
|
||||||
|
func (s *Server) handleLinkVK(c *gin.Context) {
|
||||||
|
uid, ok := userID(c)
|
||||||
|
if !ok {
|
||||||
|
abortBadRequest(c, "missing identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req linkVKBody
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||||
|
abortBadRequest(c, "missing external_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.links.ConfirmVK(c.Request.Context(), uid, req.ExternalID)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLinkVKMerge merges the account owning a gateway-validated VK identity into
|
||||||
|
// the caller's.
|
||||||
|
func (s *Server) handleLinkVKMerge(c *gin.Context) {
|
||||||
|
uid, ok := userID(c)
|
||||||
|
if !ok {
|
||||||
|
abortBadRequest(c, "missing identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req linkVKBody
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||||
|
abortBadRequest(c, "missing external_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.links.MergeVK(c.Request.Context(), uid, req.ExternalID)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
||||||
|
}
|
||||||
|
|
||||||
// confirmResultResponse renders a confirm step: a merge preview (secondary summary)
|
// confirmResultResponse renders a confirm step: a merge preview (secondary summary)
|
||||||
// or a completed link (the active account's refreshed profile).
|
// or a completed link (the active account's refreshed profile).
|
||||||
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
||||||
|
|||||||
+23
-5
@@ -1,6 +1,10 @@
|
|||||||
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
|
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
|
||||||
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; the prod
|
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; the prod
|
||||||
# deploy maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run.
|
# deploy maps the PROD_-prefixed set the same way. Values that are identical on every
|
||||||
|
# contour (DICT_VERSION, SMTP_RELAY_HOST/PORT/TLS/USER/PASS, GRAFANA_SMTP_PORT,
|
||||||
|
# VITE_VK_APP_LINK/ID, the two VK secrets) live as ONE unprefixed Gitea entry, and the
|
||||||
|
# deploy derives TELEGRAM_MINIAPP_URL / GRAFANA_ROOT_URL / VITE_VK_ID_REDIRECT_URL from
|
||||||
|
# PUBLIC_BASE_URL. Copy to deploy/.env for a local run (set the derived ones directly).
|
||||||
#
|
#
|
||||||
# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
|
# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
|
||||||
|
|
||||||
@@ -15,8 +19,9 @@ POSTGRES_PASSWORD=change-me # required
|
|||||||
# boot the dawg-data volume preserves versions uploaded through the admin console and
|
# boot the dawg-data volume preserves versions uploaded through the admin console and
|
||||||
# the active version lives in the DB. On a live volume a changed value is ignored (the
|
# the active version lives in the DB. On a live volume a changed value is ignored (the
|
||||||
# recorded .seed_version marker wins — the seed-drift guard); change a running
|
# recorded .seed_version marker wins — the seed-drift guard); change a running
|
||||||
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
|
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). One shared Gitea
|
||||||
DICT_VERSION=v1.3.0
|
# variable (DICT_VERSION) seeds both contours + pins the CI test suite.
|
||||||
|
DICT_VERSION=v1.3.1
|
||||||
|
|
||||||
# --- Logging ----------------------------------------------------------------
|
# --- Logging ----------------------------------------------------------------
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
@@ -68,10 +73,12 @@ VITE_TELEGRAM_BOT_ID=
|
|||||||
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
||||||
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
||||||
|
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
||||||
|
VITE_VK_ID_REDIRECT_URL= # VK ID trusted redirect URL (e.g. https://erudit-game.ru/app/); also the gateway's exchange redirect_uri. Deploy derives it as PUBLIC_BASE_URL + /app/
|
||||||
VITE_GATEWAY_URL=
|
VITE_GATEWAY_URL=
|
||||||
|
|
||||||
# --- Grafana ----------------------------------------------------------------
|
# --- Grafana ----------------------------------------------------------------
|
||||||
GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain
|
GRAFANA_ROOT_URL=/_gm/grafana/ # deploy derives PUBLIC_BASE_URL + /_gm/grafana/; set the full https URL for a local run
|
||||||
GRAFANA_ADMIN_PASSWORD=admin
|
GRAFANA_ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
# --- Telegram validator + bot -----------------------------------------------
|
# --- Telegram validator + bot -----------------------------------------------
|
||||||
@@ -88,7 +95,7 @@ TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; emp
|
|||||||
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
|
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
|
||||||
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
|
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
|
||||||
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
|
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
|
||||||
TELEGRAM_MINIAPP_URL= # required
|
TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_BASE_URL + /telegram/
|
||||||
TELEGRAM_TEST_ENV=false
|
TELEGRAM_TEST_ENV=false
|
||||||
TELEGRAM_API_BASE_URL=
|
TELEGRAM_API_BASE_URL=
|
||||||
|
|
||||||
@@ -97,3 +104,14 @@ TELEGRAM_API_BASE_URL=
|
|||||||
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
||||||
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
|
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
|
||||||
GATEWAY_VK_APP_SECRET=
|
GATEWAY_VK_APP_SECRET=
|
||||||
|
# VK ID web login (browser VK-identity linking) — a SEPARATE VK "Web" app: the gateway runs
|
||||||
|
# the confidential OAuth 2.1 code exchange under this protected key. The app id + redirect URL
|
||||||
|
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
||||||
|
GATEWAY_VK_ID_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Gateway anti-abuse ------------------------------------------------------
|
||||||
|
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
||||||
|
# where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value
|
||||||
|
# somewhere an attacker would find it; empty disables the trap. Gitea
|
||||||
|
# TEST_/PROD_GATEWAY_HONEYTOKEN (secret).
|
||||||
|
GATEWAY_HONEYTOKEN=
|
||||||
|
|||||||
+66
-29
@@ -46,6 +46,18 @@ runs `docker compose up -d --build` on the runner host. The prod deploy maps the
|
|||||||
**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
|
**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
|
||||||
feeds the compose's `POSTGRES_PASSWORD`, etc.
|
feeds the compose's `POSTGRES_PASSWORD`, etc.
|
||||||
|
|
||||||
|
Three naming classes in Gitea:
|
||||||
|
- **Per-contour** (`TEST_`/`PROD_<NAME>`) — values that differ between the contours
|
||||||
|
(bots, hosts, public origin, log level, email senders): the common case below.
|
||||||
|
- **Shared** (one unprefixed `<NAME>`, no prefix) — values identical on every contour,
|
||||||
|
stored once: `DICT_VERSION`, `SMTP_RELAY_HOST`/`PORT`/`TLS`/`USER`/`PASS`,
|
||||||
|
`GRAFANA_SMTP_PORT`, `VITE_VK_APP_LINK`, `VITE_VK_APP_ID`, `GATEWAY_VK_APP_SECRET`,
|
||||||
|
`GATEWAY_VK_ID_CLIENT_SECRET` (one Selectel relay + one pair of VK apps for all contours).
|
||||||
|
- **Derived** — not stored at all; the deploy computes them from `PUBLIC_BASE_URL`
|
||||||
|
(`deploy/write-prod-env.sh`, and the `ci.yaml` deploy step): `TELEGRAM_MINIAPP_URL`
|
||||||
|
(`+ /telegram/`), `GRAFANA_ROOT_URL` (`+ /_gm/grafana/`), `VITE_VK_ID_REDIRECT_URL`
|
||||||
|
(`+ /app/`). Set them directly only for a local `.env` run.
|
||||||
|
|
||||||
The deploy job also **seeds the config files** (`caddy`, `otelcol`, `prometheus`,
|
The deploy job also **seeds the config files** (`caddy`, `otelcol`, `prometheus`,
|
||||||
`tempo`, `grafana`) to a stable host path (`$HOME/.scrabble-deploy`) and sets
|
`tempo`, `grafana`) to a stable host path (`$HOME/.scrabble-deploy`) and sets
|
||||||
`SCRABBLE_CONFIG_DIR` to it before `up`. The runner's checkout is an ephemeral act
|
`SCRABBLE_CONFIG_DIR` to it before `up`. The runner's checkout is an ephemeral act
|
||||||
@@ -62,7 +74,7 @@ compose binds from this directory.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
|
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
|
||||||
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
||||||
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the bot hands out in deep links / buttons. |
|
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
|
||||||
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
||||||
|
|
||||||
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
||||||
@@ -82,11 +94,11 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
||||||
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
||||||
| `DICT_VERSION` | variable | `v1.3.0` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
|
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
|
||||||
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
|
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
|
||||||
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
|
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
|
||||||
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
||||||
| `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https://<domain>/_gm/grafana/` behind a real domain. |
|
| `GRAFANA_ROOT_URL` | derived | `/_gm/grafana/` | Grafana root URL (sub-path serving). The deploy derives `PUBLIC_BASE_URL + /_gm/grafana/`; set the full `https://<domain>/_gm/grafana/` only for a local run. |
|
||||||
| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
|
| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
|
||||||
| `TELEGRAM_GAME_CHANNEL_ID` | variable | _(empty)_ | The bot's game-channel id; empty/`0` disables channel posts. |
|
| `TELEGRAM_GAME_CHANNEL_ID` | variable | _(empty)_ | The bot's game-channel id; empty/`0` disables channel posts. |
|
||||||
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
||||||
@@ -94,20 +106,25 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
||||||
| `VITE_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). |
|
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
|
||||||
|
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
|
||||||
|
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
|
||||||
|
| `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. |
|
||||||
|
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. |
|
||||||
|
| `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. |
|
||||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||||
| `SMTP_RELAY_HOST` | variable | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
||||||
| `SMTP_RELAY_PORT` | variable | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||||
| `SMTP_RELAY_TLS` | variable | _(empty)_ | Transport security: `ssl` (implicit TLS) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); required for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). |
|
| `SMTP_RELAY_TLS` | variable (shared) | _(empty)_ | Transport security: `ssl` (implicit TLS) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); required for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). |
|
||||||
| `SMTP_RELAY_USER` | secret | _(empty)_ | Relay SMTP AUTH username. |
|
| `SMTP_RELAY_USER` | secret (shared) | _(empty)_ | Relay SMTP AUTH username. |
|
||||||
| `SMTP_RELAY_PASS` | secret | _(empty)_ | Relay SMTP AUTH password. |
|
| `SMTP_RELAY_PASS` | secret (shared) | _(empty)_ | Relay SMTP AUTH password. |
|
||||||
| `SMTP_RELAY_FROM` | variable | `no-reply@localhost` | Sender address. **Must use the prod domain** (`no-reply@erudit-game.ru`) — Selectel only accepts the verified sender domain — so it is the same on every contour. |
|
| `SMTP_RELAY_FROM` | variable | `no-reply@localhost` | Sender address. **Must use the prod domain** (`no-reply@erudit-game.ru`) — Selectel only accepts the verified sender domain — so it is the same on every contour. |
|
||||||
| `PUBLIC_BASE_URL` | variable | _(empty)_ | Canonical public https origin for links in the email (the contour's own URL, e.g. `https://erudit-game.ru`). **Required by the backend whenever `SMTP_RELAY_HOST` is set** — it is never derived from a request Host header (anti-injection). |
|
| `PUBLIC_BASE_URL` | variable | _(empty)_ | Canonical public https origin for links in the email (the contour's own URL, e.g. `https://erudit-game.ru`). **Required by the backend whenever `SMTP_RELAY_HOST` is set** — it is never derived from a request Host header (anti-injection). |
|
||||||
| `SMTP_RELAY_ADMIN_FROM` | variable | _(empty)_ | Backend operator-alert sender (new feedback / word complaints), distinct from the confirm-code From. Empty (with `ADMIN_EMAIL`) disables the alert worker. |
|
| `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. |
|
| `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. |
|
| `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}`). |
|
| `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 | _(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`. |
|
| `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. |
|
| `GF_SMTP_ENABLED` | variable | `false` | Set `true` to enable Grafana alert emails. |
|
||||||
|
|
||||||
The six `VITE_*` are **build-args** baked into the gateway and landing images at
|
The six `VITE_*` are **build-args** baked into the gateway and landing images at
|
||||||
@@ -138,14 +155,13 @@ intentionally **unset** — caddy owns `/_gm` in the contour.
|
|||||||
|
|
||||||
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
|
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
|
||||||
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
|
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
|
||||||
a build-time input with **no default** in the images, so it is set in exactly two places to
|
a build-time input with **no default** in the images. It is a single Gitea repo variable —
|
||||||
move the whole stack — change both to a new release:
|
**`DICT_VERSION`** (unprefixed, shared across contours) — so a release bump is **one edit**:
|
||||||
|
|
||||||
1. **CI tests** — `.gitea/workflows/ci.yaml` `env.DICT_VERSION` (the unit/integration jobs
|
- the CI test jobs read it via `.gitea/workflows/ci.yaml`'s top-level
|
||||||
download that dawg).
|
`env.DICT_VERSION: ${{ vars.DICT_VERSION }}` (the unit/integration jobs download that dawg), and
|
||||||
2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag
|
- both deploy jobs feed the same variable to `compose` as the `DICT_VERSION` build-arg that
|
||||||
the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as
|
bakes a **fresh** volume's seed.
|
||||||
`DICT_VERSION`).
|
|
||||||
|
|
||||||
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
|
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
|
||||||
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
|
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
|
||||||
@@ -174,6 +190,16 @@ public site. After `master` is green this workflow is the **only** thing that to
|
|||||||
prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main →
|
prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main →
|
||||||
deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
|
deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
|
||||||
|
|
||||||
|
**Maintenance page.** During the roll (and any migration window) `prod-deploy.sh` raises a
|
||||||
|
flag the edge caddy serves a static 503 "технические работы" page from
|
||||||
|
(`deploy/caddy/maintenance.html`), for the user-facing routes only — `/_gm` (Grafana) stays
|
||||||
|
reachable. The flag is cleared on any exit (success, a health failure + rollback, or an
|
||||||
|
error) by a shell trap, so it can never stick on. It arms from this feature's own deploy
|
||||||
|
onward (the caddy carrying the gate must be live first). This is **not** a zero-downtime
|
||||||
|
deploy — the backend is a single stateful instance (in-memory game state + push hub, no
|
||||||
|
Redis), so its swap still blips (clients auto-reconnect the live stream); the page just
|
||||||
|
makes the window graceful instead of raw 502s.
|
||||||
|
|
||||||
**Versioning.** Each release is a git tag `vX.Y.Z` on `master`; the deploy stamps
|
**Versioning.** Each release is a git tag `vX.Y.Z` on `master`; the deploy stamps
|
||||||
`git describe --tags` into every image tag, every binary (`-ldflags` → `pkg/version` →
|
`git describe --tags` into every image tag, every binary (`-ldflags` → `pkg/version` →
|
||||||
the `service.version` telemetry attribute) and the SPA About screen. Tag the release
|
the `service.version` telemetry attribute) and the SPA About screen. Tag the release
|
||||||
@@ -206,20 +232,31 @@ together with the fresh CA.
|
|||||||
**Sizing / monitoring:** the main host launches undersized (2 vCPU / 1.9 GiB); the prod
|
**Sizing / monitoring:** the main host launches undersized (2 vCPU / 1.9 GiB); the prod
|
||||||
overlay trims limits + `GOMAXPROCS=2` + 7d Prometheus retention, and `node_exporter` feeds
|
overlay trims limits + `GOMAXPROCS=2` + 7d Prometheus retention, and `node_exporter` feeds
|
||||||
host memory to Grafana (`/_gm/grafana/`). Watch host memory and resize at Selectel when
|
host memory to Grafana (`/_gm/grafana/`). Watch host memory and resize at Selectel when
|
||||||
players arrive.
|
players arrive. The per-container memory caps are enforced (Compose v2 → cgroup), so no one
|
||||||
|
service can eat all RAM, but they **overcommit** the host (~2.8 GiB of caps vs 1.9 GiB) — a
|
||||||
|
**1 GiB swap file** (Ansible `common` role, `swap_size`, `vm.swappiness=10`) cushions a
|
||||||
|
simultaneous spike into swap instead of the kernel OOM-killer. The `host_mem_low` Grafana
|
||||||
|
alert fires under 10% available.
|
||||||
|
|
||||||
**`PROD_` Gitea set** (mirrors `TEST_`, mapped onto the unprefixed names above) — secrets:
|
**Shared Gitea set** (one unprefixed entry each, used by every contour) — secrets:
|
||||||
|
`GATEWAY_VK_APP_SECRET, GATEWAY_VK_ID_CLIENT_SECRET, SMTP_RELAY_USER, SMTP_RELAY_PASS`;
|
||||||
|
variables: `DICT_VERSION, SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, GRAFANA_SMTP_PORT,
|
||||||
|
VITE_VK_APP_LINK, VITE_VK_APP_ID`. **Derived from `PUBLIC_BASE_URL` at deploy** (not stored):
|
||||||
|
`TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL, VITE_VK_ID_REDIRECT_URL`.
|
||||||
|
|
||||||
|
**`PROD_` Gitea set** (per-contour, mirrors `TEST_`, mapped onto the unprefixed names above)
|
||||||
|
— secrets:
|
||||||
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
||||||
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
|
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, GATEWAY_HONEYTOKEN, REGISTRY_PASSWORD, SSH_KEY,
|
||||||
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY,
|
SSH_KNOWN_HOSTS, BOTLINK_CA, BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT,
|
||||||
SMTP_RELAY_USER, SMTP_RELAY_PASS}`; variables:
|
BOTLINK_BOT_KEY}`; variables:
|
||||||
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
|
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, LOG_LEVEL,
|
||||||
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
|
TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_BOT_USERNAME,
|
||||||
TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
|
VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, VITE_TELEGRAM_GAME_CHANNEL_NAME, SMTP_RELAY_FROM,
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK,
|
PUBLIC_BASE_URL, SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL,
|
||||||
SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, SMTP_RELAY_FROM, PUBLIC_BASE_URL,
|
GF_SMTP_ENABLED}`. The test contour uses the same names under `TEST_`, minus the prod-only
|
||||||
SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL,
|
infra (`MAIN_HOST`/`TG_HOST`/`REGISTRY_*`/`SSH_*`/`BOTLINK_*`, which the test deploy generates
|
||||||
GRAFANA_SMTP_PORT, GF_SMTP_ENABLED}`.
|
or runs locally) and plus `TEST_AWG_CONF` (the bot's VPN egress).
|
||||||
|
|
||||||
## Host-side setup (outside this repo)
|
## Host-side setup (outside this repo)
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,11 @@ scrabble_base_dir: /opt/scrabble
|
|||||||
# the host's own containers (and any ad-hoc runs) rotate identically.
|
# the host's own containers (and any ad-hoc runs) rotate identically.
|
||||||
docker_log_max_size: "10m"
|
docker_log_max_size: "10m"
|
||||||
docker_log_max_file: "3"
|
docker_log_max_file: "3"
|
||||||
|
|
||||||
|
# Swap file as an OOM cushion. The per-container memory caps (docker-compose.prod.yml)
|
||||||
|
# sum to more than the tight main host's RAM (2 vCPU / 1.9 GiB), so a simultaneous
|
||||||
|
# spike could otherwise hit the kernel OOM-killer and it might pick postgres. A small
|
||||||
|
# swap absorbs the overshoot; swappiness stays low so swap is a cushion, not a hot
|
||||||
|
# path (a slow service beats a killed one). Set swap_size to "0" to skip provisioning.
|
||||||
|
swap_size: "1G"
|
||||||
|
swap_swappiness: 10
|
||||||
|
|||||||
@@ -150,6 +150,57 @@
|
|||||||
enabled: true
|
enabled: true
|
||||||
state: started
|
state: started
|
||||||
|
|
||||||
|
# --- Swap file (OOM cushion) ---------------------------------------------------
|
||||||
|
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
||||||
|
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
||||||
|
# the kernel OOM-killer (which might pick postgres). A small swap absorbs the
|
||||||
|
# overshoot. Idempotent; skipped when swap_size == "0". Builtin-only (no ansible.posix).
|
||||||
|
|
||||||
|
- name: Check whether the swap file is already active
|
||||||
|
ansible.builtin.command: swapon --show=NAME --noheadings
|
||||||
|
register: swap_active
|
||||||
|
changed_when: false
|
||||||
|
when: swap_size != "0"
|
||||||
|
|
||||||
|
- name: Allocate the swap file
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: "fallocate -l {{ swap_size }} /swapfile"
|
||||||
|
creates: /swapfile
|
||||||
|
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
|
||||||
|
|
||||||
|
- name: Secure the swap file (0600)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /swapfile
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
when: swap_size != "0"
|
||||||
|
|
||||||
|
- name: Format and enable the swap file
|
||||||
|
ansible.builtin.shell: "mkswap /swapfile && swapon /swapfile"
|
||||||
|
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
|
||||||
|
|
||||||
|
- name: Persist the swap file in /etc/fstab
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: /etc/fstab
|
||||||
|
line: "/swapfile none swap sw 0 0"
|
||||||
|
regexp: '^/swapfile\s'
|
||||||
|
state: present
|
||||||
|
when: swap_size != "0"
|
||||||
|
|
||||||
|
- name: Keep swap a cushion, not a hot path (low swappiness)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/sysctl.d/60-scrabble-swap.conf
|
||||||
|
mode: "0644"
|
||||||
|
content: "vm.swappiness = {{ swap_swappiness }}\n"
|
||||||
|
register: swappiness_conf
|
||||||
|
when: swap_size != "0"
|
||||||
|
|
||||||
|
- name: Apply swappiness now
|
||||||
|
ansible.builtin.command: sysctl --system
|
||||||
|
changed_when: false
|
||||||
|
when: swap_size != "0" and swappiness_conf is changed
|
||||||
|
|
||||||
# --- Deploy directories --------------------------------------------------------
|
# --- Deploy directories --------------------------------------------------------
|
||||||
|
|
||||||
- name: Create the scrabble base directories
|
- name: Create the scrabble base directories
|
||||||
|
|||||||
+34
-1
@@ -33,6 +33,39 @@
|
|||||||
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
|
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
|
||||||
header Alt-Svc clear
|
header Alt-Svc clear
|
||||||
|
|
||||||
|
# Maintenance gate. While the deploy holds the flag file /srv/maint/on (touched by
|
||||||
|
# prod-deploy.sh around a rolling swap, removed on the script's exit), serve a static
|
||||||
|
# 503 "works in progress" page instead of proxying to a mid-recreate upstream —
|
||||||
|
# a graceful signal rather than raw 502s. Operator surfaces (/_gm) are excluded so
|
||||||
|
# Grafana stays reachable during the window. Caddy stat()s the flag per request, so
|
||||||
|
# toggling it needs no reload; the page + flag live in the caddy config dir bind-mounted
|
||||||
|
# read-only at /srv/maint (docker-compose.yml). The test contour never sets the flag
|
||||||
|
# (only prod-deploy.sh does), so this is inert there.
|
||||||
|
@maintenance {
|
||||||
|
not path /_gm /_gm/*
|
||||||
|
file {
|
||||||
|
root /srv/maint
|
||||||
|
try_files on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle @maintenance {
|
||||||
|
error 503
|
||||||
|
}
|
||||||
|
handle_errors {
|
||||||
|
@maint503 expression {err.status_code} == 503
|
||||||
|
handle @maint503 {
|
||||||
|
root * /srv/maint
|
||||||
|
rewrite * /maintenance.html
|
||||||
|
header Retry-After 120
|
||||||
|
# Distinctive maintenance marker so the SPA can tell a planned window apart from
|
||||||
|
# a transient upstream 503 and show its own dimmed overlay (an in-session user
|
||||||
|
# never sees this static page — it only renders on a fresh load). The header
|
||||||
|
# rides every gated response, incl. the Connect/gRPC edge the app polls.
|
||||||
|
header X-Scrabble-Maintenance 1
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Operator surfaces under /_gm: a single shared Basic-Auth, then route.
|
# Operator surfaces under /_gm: a single shared Basic-Auth, then route.
|
||||||
@gm path /_gm /_gm/*
|
@gm path /_gm /_gm/*
|
||||||
handle @gm {
|
handle @gm {
|
||||||
@@ -53,7 +86,7 @@
|
|||||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
# 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
|
# 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).
|
# 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 {
|
handle @gateway {
|
||||||
reverse_proxy gateway:8081 {
|
reverse_proxy gateway:8081 {
|
||||||
header_up -X-Scrabble-Honeypot
|
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>
|
||||||
@@ -190,6 +190,8 @@ services:
|
|||||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
||||||
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
||||||
|
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
|
||||||
|
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
||||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||||
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
|
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
|
||||||
@@ -207,6 +209,14 @@ services:
|
|||||||
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
|
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
|
||||||
# the VK auth path (auth.vk is then unregistered).
|
# the VK auth path (auth.vk is then unregistered).
|
||||||
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
|
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
|
||||||
|
# VK ID web login (browser VK-identity linking): the confidential OAuth 2.1 code
|
||||||
|
# exchange runs server-side under the VK ID "Web" app's protected key. This is a
|
||||||
|
# SEPARATE VK app from the Mini App above, so the credentials are distinct. The app id
|
||||||
|
# and redirect URL are the same values the SPA builds its authorize URL from (one
|
||||||
|
# source each). All three empty disables the link.vk.* ops.
|
||||||
|
GATEWAY_VK_ID_APP_ID: ${VITE_VK_APP_ID:-}
|
||||||
|
GATEWAY_VK_ID_CLIENT_SECRET: ${GATEWAY_VK_ID_CLIENT_SECRET:-}
|
||||||
|
GATEWAY_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
||||||
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
|
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
|
||||||
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
|
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
|
||||||
# listeners stay on the internal network (the bot shares the VPN netns); in prod
|
# listeners stay on the internal network (the bot shares the VPN netns); in prod
|
||||||
@@ -217,10 +227,12 @@ services:
|
|||||||
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
|
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
|
||||||
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
|
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
|
||||||
# Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the
|
# Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the
|
||||||
# honeypot/honeytoken. Off by default: it bans by client IP, which is only
|
# honeypot/honeytoken. Off by default: it bans by client IP, which is only real
|
||||||
# real in prod — the test contour arrives as one shared NAT address, so a ban
|
# in prod — the test contour arrives as one shared NAT address, so a ban there
|
||||||
# there would be self-inflicted (the honeypot/honeytoken still log). Prod sets
|
# would be self-inflicted (the honeypot still logs). The prod deploy forces it on
|
||||||
# these from PROD_ inputs; GATEWAY_HONEYTOKEN is the planted bearer trap.
|
# in env.sh (deploy/write-prod-env.sh), not via a Gitea variable. GATEWAY_HONEYTOKEN
|
||||||
|
# is the planted bearer trap, fed from the per-contour TEST_/PROD_GATEWAY_HONEYTOKEN
|
||||||
|
# secret; empty (unset secret) leaves the trap off.
|
||||||
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
||||||
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
||||||
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
@@ -264,6 +276,8 @@ services:
|
|||||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
|
||||||
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
|
||||||
|
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
|
||||||
|
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
||||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -414,6 +428,11 @@ services:
|
|||||||
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
|
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
|
||||||
volumes:
|
volumes:
|
||||||
- ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
- ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
# Maintenance page + toggle flag: the caddy config dir holds maintenance.html and the
|
||||||
|
# `on` flag prod-deploy.sh touches around a rolling swap; the Caddyfile serves a 503
|
||||||
|
# from here while the flag exists (read-only mount — the deploy writes the flag on the
|
||||||
|
# host side). See deploy/caddy/Caddyfile and deploy/prod-deploy.sh.
|
||||||
|
- ${SCRABBLE_CONFIG_DIR:-.}/caddy:/srv/maint:ro
|
||||||
- caddy-data:/data
|
- caddy-data:/data
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
@@ -56,6 +56,22 @@
|
|||||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
|
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }]
|
"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}}" }]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ PREV_STATE_FILE="${PREV_STATE_FILE:-/opt/scrabble/PREVIOUS_TAG}"
|
|||||||
PG_USER="${POSTGRES_USER:-scrabble}"
|
PG_USER="${POSTGRES_USER:-scrabble}"
|
||||||
PG_DB="${POSTGRES_DB:-scrabble}"
|
PG_DB="${POSTGRES_DB:-scrabble}"
|
||||||
|
|
||||||
|
# Edge maintenance page. caddy serves a static 503 "works in progress" page for the
|
||||||
|
# user-facing routes while this flag file exists (deploy/caddy/Caddyfile). We hold it
|
||||||
|
# across the roll / migration window and clear it on ANY exit — success, a health
|
||||||
|
# failure + rollback, or an unexpected error — via the trap, so users get a graceful
|
||||||
|
# page instead of raw mid-swap 502s and the flag can never get stuck on.
|
||||||
|
MAINT_FLAG="${MAINT_FLAG:-${SCRABBLE_CONFIG_DIR:-/opt/scrabble}/caddy/on}"
|
||||||
|
maint_off() { rm -f "$MAINT_FLAG" 2>/dev/null || true; }
|
||||||
|
trap maint_off EXIT
|
||||||
|
|
||||||
cd "$COMPOSE_DIR" || { echo "compose dir $COMPOSE_DIR missing"; exit 1; }
|
cd "$COMPOSE_DIR" || { echo "compose dir $COMPOSE_DIR missing"; exit 1; }
|
||||||
export REGISTRY
|
export REGISTRY
|
||||||
# otelcol joins the host docker group to read the socket; the GID varies per host.
|
# otelcol joins the host docker group to read the socket; the GID varies per host.
|
||||||
@@ -119,6 +128,13 @@ if [ -z "$(docker ps -aq -f name=scrabble-backend)" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Existing stack: raise the maintenance page for the whole roll (and any migration
|
||||||
|
# window). It shows once caddy carries the gate (from this feature's own deploy
|
||||||
|
# onward); the EXIT trap lowers it however this run ends.
|
||||||
|
mkdir -p "$(dirname "$MAINT_FLAG")"
|
||||||
|
: > "$MAINT_FLAG"
|
||||||
|
echo "maintenance page raised ($MAINT_FLAG)"
|
||||||
|
|
||||||
# Migration deploy: freeze writes and snapshot a consistent dump before migrating.
|
# Migration deploy: freeze writes and snapshot a consistent dump before migrating.
|
||||||
if [ "$MIGRATION" = 1 ]; then
|
if [ "$MIGRATION" = 1 ]; then
|
||||||
echo "migration deploy: opening maintenance window (stopping the backend = the only writer)"
|
echo "migration deploy: opening maintenance window (stopping the backend = the only writer)"
|
||||||
|
|||||||
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
|
||||||
+26
-4
@@ -257,11 +257,19 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
control of the identity before attaching it: **email** through the confirm-code
|
control of the identity before attaching it: **email** through the confirm-code
|
||||||
flow, **Telegram** through the web **Login Widget** (validated by the validator,
|
flow, **Telegram** through the web **Login Widget** (validated by the validator,
|
||||||
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
|
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
|
||||||
passes the trusted `external_id` to the backend, as for `auth.telegram`). The
|
passes the trusted `external_id` to the backend, as for `auth.telegram`), and **VK**
|
||||||
|
through **VK ID web login** (`gateway/internal/vkid`): the browser runs the VK ID raw
|
||||||
|
OAuth 2.1 flow with PKCE — a full-page redirect to VK's hosted login, no `@vkid/sdk` —
|
||||||
|
and the gateway completes the **confidential code exchange** server-side under the VK
|
||||||
|
"Web" app's protected key (a distinct VK app from the Mini App) to obtain the trusted
|
||||||
|
`external_id`. A browser has no signed launch parameters, so unlike the VK Mini App
|
||||||
|
(offline HMAC, §12) this makes an outbound call to `id.vk.com`, and it is web-only — a
|
||||||
|
full-page redirect would strand a native Mini App webview. The
|
||||||
request step **always** sends/accepts the proof (no pre-send "already taken"
|
request step **always** sends/accepts the proof (no pre-send "already taken"
|
||||||
signal, so a probe cannot enumerate registered addresses); a required **merge**
|
signal, so a probe cannot enumerate registered addresses); a required **merge**
|
||||||
is revealed **only after** the proof is verified and is performed behind an
|
is revealed **only after** the proof is verified and is performed behind an
|
||||||
explicit, irreversible confirmation. A free identity is simply attached (and a
|
explicit, irreversible confirmation (for VK, whose authorization code is single-use,
|
||||||
|
the merge re-authorizes for a fresh code). A free identity is simply attached (and a
|
||||||
guest is promoted to durable, clearing `is_guest`).
|
guest is promoted to durable, clearing `is_guest`).
|
||||||
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
|
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
|
||||||
backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
|
backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
|
||||||
@@ -284,8 +292,11 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
invitations / friend-codes / drafts / pending-codes. **Chat, feedback and complaints are
|
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
|
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
|
not stranded), **drops its all-robot games** (no human opponent; children cascade), and
|
||||||
revokes its sessions. Every credential detachment — **unlink, email change and delete** —
|
revokes its sessions. Every credential detachment — **unlink, email change, delete and a
|
||||||
writes a `retained_identities` row (`reason`), so the dossier keeps the full timeline.
|
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;
|
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.
|
`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
|
`last_login_at` / `last_login_ip` are stamped on the cold-load profile fetch (throttled
|
||||||
@@ -1059,6 +1070,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
|
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
|
design (a dropped beacon just retries its batch on the next flush); it is telemetry, never
|
||||||
a game input.
|
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
|
- **Rate-limit observability:** every limiter rejection increments the gateway
|
||||||
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
|
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;
|
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
|
||||||
|
|||||||
+4
-2
@@ -108,8 +108,10 @@ 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.
|
two accounts share a game still in progress.
|
||||||
|
|
||||||
The profile lists the account's **sign-in methods**. On the web a player can add
|
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
|
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
|
||||||
platform is already linked. A linked provider can be **unlinked** — except the last
|
back); inside a Mini App the host platform is already linked. Linking a provider that
|
||||||
|
already belongs to another account offers the same irreversible **merge** as email
|
||||||
|
linking. A linked provider can be **unlinked** — except the last
|
||||||
remaining sign-in method, which is refused so the account stays reachable. **Email is
|
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
|
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
|
mailed to it, and the account switches to it atomically, freeing the old address. A new
|
||||||
|
|||||||
@@ -112,8 +112,10 @@ Telegram держит **единого бота**: все игроки поль
|
|||||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||||
|
|
||||||
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
||||||
Telegram; добавление VK в вебе пока не предлагается, а внутри Mini App платформа-хозяин
|
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
||||||
уже привязана. Привязанного провайдера можно **отвязать** — кроме последнего
|
обратно); внутри Mini App платформа-хозяин уже привязана. Привязка провайдера, уже
|
||||||
|
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
|
||||||
|
email. Привязанного провайдера можно **отвязать** — кроме последнего
|
||||||
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
|
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
|
||||||
**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
|
**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
|
||||||
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
|
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ ARG VITE_TELEGRAM_BOT_ID=
|
|||||||
ARG VITE_TELEGRAM_LINK=
|
ARG VITE_TELEGRAM_LINK=
|
||||||
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME=
|
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME=
|
||||||
ARG VITE_VK_APP_LINK=
|
ARG VITE_VK_APP_LINK=
|
||||||
|
ARG VITE_VK_APP_ID=
|
||||||
|
ARG VITE_VK_ID_REDIRECT_URL=
|
||||||
ARG VITE_GATEWAY_URL=
|
ARG VITE_GATEWAY_URL=
|
||||||
ARG VITE_APP_VERSION=
|
ARG VITE_APP_VERSION=
|
||||||
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||||
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
|
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
|
||||||
VITE_VK_APP_LINK=$VITE_VK_APP_LINK \
|
VITE_VK_APP_LINK=$VITE_VK_APP_LINK \
|
||||||
|
VITE_VK_APP_ID=$VITE_VK_APP_ID \
|
||||||
|
VITE_VK_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_URL \
|
||||||
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
|
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
|
||||||
VITE_APP_VERSION=$VITE_APP_VERSION
|
VITE_APP_VERSION=$VITE_APP_VERSION
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -61,6 +61,12 @@ round-trip, then forwards the trusted `vk_user_id` (and the client-read display
|
|||||||
from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled
|
from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled
|
||||||
(`auth.vk` is unregistered).
|
(`auth.vk` is unregistered).
|
||||||
|
|
||||||
|
`link.vk.confirm`/`merge` link a VK identity from a **browser** (not a Mini App) via **VK ID web
|
||||||
|
login** (`internal/vkid`): the SPA runs the VK ID raw OAuth 2.1 flow (PKCE, no `@vkid/sdk`) and
|
||||||
|
the gateway completes the **confidential code exchange** at `id.vk.com` under a SEPARATE VK "Web"
|
||||||
|
app's protected key — the only op here that calls VK (the Mini App path above is offline). All
|
||||||
|
three `GATEWAY_VK_ID_*` unset leaves the ops unregistered.
|
||||||
|
|
||||||
The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`,
|
The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`,
|
||||||
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
||||||
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
|
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
|
||||||
@@ -72,9 +78,10 @@ refetch). The social/account/history ops —
|
|||||||
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
|
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
|
||||||
`stats.get`, `game.gcg`, and the `notify` live event — go through the identical
|
`stats.get`, `game.gcg`, and the `notify` live event — go through the identical
|
||||||
transcode pattern (`transcode_social.go`). Account linking & merge
|
transcode pattern (`transcode_social.go`). Account linking & merge
|
||||||
— `link.email.request/confirm/merge` and `link.telegram.confirm/merge`
|
— `link.email.request/confirm/merge`, `link.telegram.confirm/merge` and
|
||||||
(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the
|
`link.vk.confirm/merge` (`transcode_link.go`); the telegram ops validate the **Login
|
||||||
validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
Widget** payload via the validator (`ValidateLoginWidget`), the vk ops complete the VK ID
|
||||||
|
web code exchange via `internal/vkid`, and both forward the trusted `external_id`. These
|
||||||
**superseded** the former `email.bind.*` ops, which were removed.
|
**superseded** the former `email.bind.*` ops, which were removed.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -89,6 +96,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
|||||||
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
|
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
|
||||||
| `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
|
| `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
|
||||||
| `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) |
|
| `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) |
|
||||||
|
| `GATEWAY_VK_ID_APP_ID` / `GATEWAY_VK_ID_CLIENT_SECRET` / `GATEWAY_VK_ID_REDIRECT_URL` | unset | VK ID "Web" app credentials for VK web-login linking (`link.vk.*`): the server-side confidential OAuth 2.1 code exchange. A separate VK app from `GATEWAY_VK_APP_SECRET`; all three required to enable the ops |
|
||||||
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
|
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
|
||||||
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
|
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
|
||||||
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
|
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
"scrabble/gateway/internal/ratelimit"
|
"scrabble/gateway/internal/ratelimit"
|
||||||
"scrabble/gateway/internal/session"
|
"scrabble/gateway/internal/session"
|
||||||
"scrabble/gateway/internal/transcode"
|
"scrabble/gateway/internal/transcode"
|
||||||
|
"scrabble/gateway/internal/vkid"
|
||||||
"scrabble/pkg/mtls"
|
"scrabble/pkg/mtls"
|
||||||
botlinkv1 "scrabble/pkg/proto/botlink/v1"
|
botlinkv1 "scrabble/pkg/proto/botlink/v1"
|
||||||
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
||||||
@@ -190,7 +191,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
|
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
|
||||||
}
|
}
|
||||||
|
|
||||||
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret))
|
// VK ID web login (browser VK-identity linking) is optional: build the confidential
|
||||||
|
// code-exchanger only when fully configured, else leave the interface nil so the
|
||||||
|
// link.vk.* ops stay unregistered.
|
||||||
|
var vkidExchanger transcode.VKIDExchanger
|
||||||
|
if cfg.VKID.Enabled() {
|
||||||
|
vkidExchanger = vkid.New(cfg.VKID.AppID, cfg.VKID.ClientSecret, cfg.VKID.RedirectURI)
|
||||||
|
logger.Info("vk id web login enabled")
|
||||||
|
}
|
||||||
|
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger))
|
||||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
|
|||||||
@@ -335,6 +335,24 @@ func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID strin
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinkVK attaches a gateway-validated VK identity to the caller or reports a required
|
||||||
|
// merge. externalID is the trusted vk user id resolved from the VK ID code exchange.
|
||||||
|
func (c *Client) LinkVK(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
|
||||||
|
var out LinkResultResp
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/vk", userID, "",
|
||||||
|
map[string]string{"external_id": externalID}, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkVKMerge merges the account owning a gateway-validated VK identity into the
|
||||||
|
// caller's.
|
||||||
|
func (c *Client) LinkVKMerge(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
|
||||||
|
var out LinkResultResp
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/vk/merge", userID, "",
|
||||||
|
map[string]string{"external_id": externalID}, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
// ChangeEmailRequest asks the backend to mail a confirm-code to a new address for an
|
// ChangeEmailRequest asks the backend to mail a confirm-code to a new address for an
|
||||||
// authenticated email change.
|
// authenticated email change.
|
||||||
func (c *Client) ChangeEmailRequest(ctx context.Context, userID, email string) error {
|
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
|
// VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API
|
||||||
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
|
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
|
||||||
VKAppSecret string
|
VKAppSecret string
|
||||||
|
// VKID configures the VK ID web login used to link a VK identity from a browser
|
||||||
|
// (the confidential OAuth 2.1 code exchange against id.vk.com). It belongs to a
|
||||||
|
// separate VK "Web" app from VKAppSecret's Mini App, so its credentials are distinct.
|
||||||
|
// Any field empty disables the VK web-link ops (link.vk.*).
|
||||||
|
VKID VKIDConfig
|
||||||
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
|
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
|
||||||
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
|
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
|
||||||
BotLink BotLinkConfig
|
BotLink BotLinkConfig
|
||||||
@@ -161,6 +166,22 @@ func DefaultAbuse() AbuseConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VKIDConfig holds the VK ID web-login credentials for the confidential
|
||||||
|
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
|
||||||
|
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
|
||||||
|
// with the app and the one the frontend uses. All three are required to enable the flow.
|
||||||
|
type VKIDConfig struct {
|
||||||
|
AppID string
|
||||||
|
ClientSecret string
|
||||||
|
RedirectURI string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether VK ID web login is fully configured. When false the gateway
|
||||||
|
// leaves the VK web-link ops (link.vk.*) unregistered.
|
||||||
|
func (c VKIDConfig) Enabled() bool {
|
||||||
|
return c.AppID != "" && c.ClientSecret != "" && c.RedirectURI != ""
|
||||||
|
}
|
||||||
|
|
||||||
// Load reads the configuration from the environment, applies defaults, and
|
// Load reads the configuration from the environment, applies defaults, and
|
||||||
// validates the result.
|
// validates the result.
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -174,6 +195,11 @@ func Load() (Config, error) {
|
|||||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||||
|
VKID: VKIDConfig{
|
||||||
|
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
||||||
|
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
|
||||||
|
RedirectURI: os.Getenv("GATEWAY_VK_ID_REDIRECT_URL"),
|
||||||
|
},
|
||||||
SessionCacheMax: defaultSessionCacheMax,
|
SessionCacheMax: defaultSessionCacheMax,
|
||||||
RateLimit: DefaultRateLimit(),
|
RateLimit: DefaultRateLimit(),
|
||||||
Abuse: DefaultAbuse(),
|
Abuse: DefaultAbuse(),
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ type serverMetrics struct {
|
|||||||
localColdStart metric.Int64Counter
|
localColdStart metric.Int64Counter
|
||||||
localDictLoad metric.Int64Counter
|
localDictLoad metric.Int64Counter
|
||||||
localPreview 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),
|
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
|
||||||
@@ -63,6 +65,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."),
|
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)."),
|
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)."),
|
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",
|
gauge, err := meter.Int64ObservableGauge("active_users",
|
||||||
@@ -108,6 +112,16 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
|
|||||||
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
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
|
// 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
|
// the client's previous report. It backs the adoption dashboard: app cold starts vs cached
|
||||||
// dictionaries vs on-device previews.
|
// dictionaries vs on-device previews.
|
||||||
|
|||||||
@@ -128,3 +128,70 @@ func TestBannedMetric(t *testing.T) {
|
|||||||
t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts)
|
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"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -197,6 +198,9 @@ func (s *Server) HTTPHandler() http.Handler {
|
|||||||
mux.Handle("/dl/", s.exportDownloadHandler())
|
mux.Handle("/dl/", s.exportDownloadHandler())
|
||||||
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
||||||
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
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
|
// 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
|
// 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
|
// §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)
|
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
|
// 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
|
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
||||||
// or unknown.
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
"scrabble/gateway/internal/connector"
|
"scrabble/gateway/internal/connector"
|
||||||
"scrabble/gateway/internal/vkauth"
|
"scrabble/gateway/internal/vkauth"
|
||||||
|
"scrabble/gateway/internal/vkid"
|
||||||
fb "scrabble/pkg/fbs/scrabblefb"
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,6 +153,15 @@ func WithVKAuth(secret string) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithVKLink registers the VK web-link ops (link.vk.confirm/merge), which complete a
|
||||||
|
// browser VK ID authorization via the server-side code exchange. A nil exchanger leaves
|
||||||
|
// them unregistered, so the ops are unknown wherever VK ID web login is not configured.
|
||||||
|
func WithVKLink(ex VKIDExchanger) Option {
|
||||||
|
return func(r *Registry, backend *backendclient.Client) {
|
||||||
|
registerVKLinkOps(r, backend, ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lookup returns the operation for messageType, and whether it is registered.
|
// Lookup returns the operation for messageType, and whether it is registered.
|
||||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||||
op, ok := r.ops[messageType]
|
op, ok := r.ops[messageType]
|
||||||
@@ -172,6 +182,9 @@ func DomainCode(err error) (string, bool) {
|
|||||||
if errors.Is(err, vkauth.ErrInvalid) {
|
if errors.Is(err, vkauth.ErrInvalid) {
|
||||||
return "invalid_vk_params", true
|
return "invalid_vk_params", true
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, vkid.ErrInvalid) {
|
||||||
|
return "invalid_vk_id", true
|
||||||
|
}
|
||||||
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
||||||
return "invalid_login_widget", true
|
return "invalid_login_widget", true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
|
"scrabble/gateway/internal/vkid"
|
||||||
fb "scrabble/pkg/fbs/scrabblefb"
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ const (
|
|||||||
MsgLinkEmailMerge = "link.email.merge"
|
MsgLinkEmailMerge = "link.email.merge"
|
||||||
MsgLinkTelegram = "link.telegram.confirm"
|
MsgLinkTelegram = "link.telegram.confirm"
|
||||||
MsgLinkTelegramMerge = "link.telegram.merge"
|
MsgLinkTelegramMerge = "link.telegram.merge"
|
||||||
|
MsgLinkVK = "link.vk.confirm"
|
||||||
|
MsgLinkVKMerge = "link.vk.merge"
|
||||||
MsgLinkUnlink = "link.unlink"
|
MsgLinkUnlink = "link.unlink"
|
||||||
MsgEmailChangeRequest = "link.email.change.request"
|
MsgEmailChangeRequest = "link.email.change.request"
|
||||||
MsgEmailChangeConfirm = "link.email.change.confirm"
|
MsgEmailChangeConfirm = "link.email.change.confirm"
|
||||||
@@ -154,3 +157,44 @@ func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, me
|
|||||||
return encodeLinkResult(res), nil
|
return encodeLinkResult(res), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VKIDExchanger completes a VK ID web authorization-code exchange, returning the
|
||||||
|
// launching user's trusted VK identity. It is satisfied by *vkid.Exchanger and lets the
|
||||||
|
// VK web-link ops resolve a browser VK login, which — unlike the Mini App path — has no
|
||||||
|
// offline launch signature to verify.
|
||||||
|
type VKIDExchanger interface {
|
||||||
|
Exchange(ctx context.Context, code, deviceID, codeVerifier string) (vkid.Identity, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerVKLinkOps adds the VK web-link ops when a VK ID exchanger is configured; a nil
|
||||||
|
// exchanger leaves them unregistered (VK ID web login not configured).
|
||||||
|
func registerVKLinkOps(r *Registry, backend *backendclient.Client, ex VKIDExchanger) {
|
||||||
|
if ex == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.ops[MsgLinkVK] = Op{Handler: linkVKHandler(backend, ex, false), Auth: true}
|
||||||
|
r.ops[MsgLinkVKMerge] = Op{Handler: linkVKHandler(backend, ex, true), Auth: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkVKHandler completes the VK ID code exchange (server-side, under the app's
|
||||||
|
// protected key) and then calls the backend's link or merge endpoint with the trusted
|
||||||
|
// VK external id.
|
||||||
|
func linkVKHandler(backend *backendclient.Client, ex VKIDExchanger, merge bool) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
in := fb.GetRootAsLinkVKRequest(req.Payload, 0)
|
||||||
|
user, err := ex.Exchange(ctx, string(in.Code()), string(in.DeviceId()), string(in.CodeVerifier()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var res backendclient.LinkResultResp
|
||||||
|
if merge {
|
||||||
|
res, err = backend.LinkVKMerge(ctx, req.UserID, user.ExternalID)
|
||||||
|
} else {
|
||||||
|
res, err = backend.LinkVK(ctx, req.UserID, user.ExternalID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeLinkResult(res), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package transcode_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/transcode"
|
||||||
|
"scrabble/gateway/internal/vkid"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func linkVKPayload(code, deviceID, verifier string) []byte {
|
||||||
|
b := flatbuffers.NewBuilder(64)
|
||||||
|
c := b.CreateString(code)
|
||||||
|
d := b.CreateString(deviceID)
|
||||||
|
v := b.CreateString(verifier)
|
||||||
|
fb.LinkVKRequestStart(b)
|
||||||
|
fb.LinkVKRequestAddCode(b, c)
|
||||||
|
fb.LinkVKRequestAddDeviceId(b, d)
|
||||||
|
fb.LinkVKRequestAddCodeVerifier(b, v)
|
||||||
|
b.Finish(fb.LinkVKRequestEnd(b))
|
||||||
|
return b.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeVKIDExchanger records the exchange inputs and returns a canned identity.
|
||||||
|
type fakeVKIDExchanger struct {
|
||||||
|
id vkid.Identity
|
||||||
|
err error
|
||||||
|
gotCode, gotDevice, gotVerifier string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeVKIDExchanger) Exchange(_ context.Context, code, deviceID, codeVerifier string) (vkid.Identity, error) {
|
||||||
|
f.gotCode, f.gotDevice, f.gotVerifier = code, deviceID, codeVerifier
|
||||||
|
return f.id, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkVKExchangesAndForwards(t *testing.T) {
|
||||||
|
var gotExternalID string
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/user/link/vk" {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ExternalID string `json:"external_id"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
gotExternalID = body.ExternalID
|
||||||
|
_, _ = w.Write([]byte(`{"status":"linked"}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ex := &fakeVKIDExchanger{id: vkid.Identity{ExternalID: "777"}}
|
||||||
|
reg := transcode.NewRegistry(backend, nil, transcode.WithVKLink(ex))
|
||||||
|
op, ok := reg.Lookup(transcode.MsgLinkVK)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("link.vk.confirm not registered")
|
||||||
|
}
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: linkVKPayload("the-code", "dev-1", "verifier-1")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
// The PKCE inputs from the wire must reach the exchanger verbatim...
|
||||||
|
if ex.gotCode != "the-code" || ex.gotDevice != "dev-1" || ex.gotVerifier != "verifier-1" {
|
||||||
|
t.Errorf("exchange got %q/%q/%q", ex.gotCode, ex.gotDevice, ex.gotVerifier)
|
||||||
|
}
|
||||||
|
// ...and the resolved vk id must be the one forwarded to the backend.
|
||||||
|
if gotExternalID != "777" {
|
||||||
|
t.Errorf("backend external_id = %q, want 777", gotExternalID)
|
||||||
|
}
|
||||||
|
if string(fb.GetRootAsLinkResult(payload, 0).Status()) != "linked" {
|
||||||
|
t.Error("expected a linked result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkVKUnregisteredWithoutExchanger(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
if _, ok := reg.Lookup(transcode.MsgLinkVK); ok {
|
||||||
|
t.Error("link.vk.confirm must be unregistered when no VK ID exchanger is configured")
|
||||||
|
}
|
||||||
|
if _, ok := reg.Lookup(transcode.MsgLinkVKMerge); ok {
|
||||||
|
t.Error("link.vk.merge must be unregistered when no VK ID exchanger is configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Package vkid completes VK ID web authorization on the server. A browser linking a
|
||||||
|
// VK identity has no signed Mini App launch parameters (that offline HMAC path is
|
||||||
|
// vkauth); instead the frontend runs the VK ID raw OAuth 2.1 flow (PKCE) and hands the
|
||||||
|
// gateway the authorization code, which the gateway exchanges — confidentially, under
|
||||||
|
// the app's protected key — for the launching user's trusted VK id. Unlike the Mini App
|
||||||
|
// path this makes an outbound call to VK (there is no offline verification for the web
|
||||||
|
// flow). See docs/ARCHITECTURE.md §12.
|
||||||
|
package vkid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// tokenEndpoint is VK ID's OAuth 2.1 token endpoint. The frontend obtains the
|
||||||
|
// authorization code against the same host, so the confidential exchange targets it
|
||||||
|
// too. It is a fixed constant (not user input), so the outbound request carries no
|
||||||
|
// SSRF risk.
|
||||||
|
tokenEndpoint = "https://id.vk.com/oauth2/auth"
|
||||||
|
// exchangeTimeout bounds one code-for-token exchange. Linking is interactive, so an
|
||||||
|
// unreachable VK must fail fast rather than hold the request open.
|
||||||
|
exchangeTimeout = 10 * time.Second
|
||||||
|
// maxResponseBytes caps the token-response read to bound memory on an oversized body.
|
||||||
|
maxResponseBytes = 1 << 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalid reports an authorization fault: the exchange was rejected or yielded no vk
|
||||||
|
// user id (a bad or expired code, a mismatched verifier or redirect). It is distinct
|
||||||
|
// from a transport failure reaching VK, which surfaces as a wrapped error.
|
||||||
|
var ErrInvalid = errors.New("vkid: vk id authorization exchange failed")
|
||||||
|
|
||||||
|
// Identity is the user resolved from a completed VK ID exchange. ExternalID is the vk
|
||||||
|
// user id, used as the identities external_id.
|
||||||
|
type Identity struct {
|
||||||
|
ExternalID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// numericID accepts a VK user id that the token endpoint returns inconsistently as a
|
||||||
|
// JSON string or a JSON number, normalising both to their decimal string form (empty
|
||||||
|
// for a null or absent field).
|
||||||
|
type numericID string
|
||||||
|
|
||||||
|
func (n *numericID) UnmarshalJSON(b []byte) error {
|
||||||
|
s := strings.Trim(string(b), `"`)
|
||||||
|
if s == "null" {
|
||||||
|
s = ""
|
||||||
|
}
|
||||||
|
*n = numericID(s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchanger completes VK ID confidential authorization-code exchanges for one app.
|
||||||
|
type Exchanger struct {
|
||||||
|
appID string
|
||||||
|
clientSecret string
|
||||||
|
redirectURI string
|
||||||
|
endpoint string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs an Exchanger for the app credentials. redirectURI must equal the
|
||||||
|
// trusted redirect URL registered with the app and the one the frontend used, or VK
|
||||||
|
// rejects the exchange.
|
||||||
|
func New(appID, clientSecret, redirectURI string) *Exchanger {
|
||||||
|
return &Exchanger{
|
||||||
|
appID: appID,
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
redirectURI: redirectURI,
|
||||||
|
endpoint: tokenEndpoint,
|
||||||
|
httpClient: &http.Client{Timeout: exchangeTimeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange completes the authorization-code grant and returns the trusted vk user id.
|
||||||
|
// code, deviceID and codeVerifier are the PKCE inputs the frontend obtained from the VK
|
||||||
|
// ID authorization redirect; the exchange authenticates with the app's protected key.
|
||||||
|
func (e *Exchanger) Exchange(ctx context.Context, code, deviceID, codeVerifier string) (Identity, error) {
|
||||||
|
if code == "" || deviceID == "" || codeVerifier == "" {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"code": {code},
|
||||||
|
"code_verifier": {codeVerifier},
|
||||||
|
"device_id": {deviceID},
|
||||||
|
"client_id": {e.appID},
|
||||||
|
"client_secret": {e.clientSecret},
|
||||||
|
"redirect_uri": {e.redirectURI},
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.endpoint, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return Identity{}, fmt.Errorf("vkid: build exchange request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := e.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Identity{}, fmt.Errorf("vkid: exchange request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||||
|
if err != nil {
|
||||||
|
return Identity{}, fmt.Errorf("vkid: read exchange response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// VK answers 400 with {error, error_description} on a bad/expired code or a
|
||||||
|
// verifier/redirect mismatch — an authorization fault, not a transport error.
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
UserID numericID `json:"user_id"`
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
if out.Error != "" {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
uid := string(out.UserID)
|
||||||
|
if uid == "" || uid == "0" {
|
||||||
|
uid = subjectFromIDToken(out.IDToken)
|
||||||
|
}
|
||||||
|
if uid == "" {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
return Identity{ExternalID: uid}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// subjectFromIDToken extracts the OIDC subject (the vk user id) from the payload of a
|
||||||
|
// VK ID id_token. The token arrives inside a direct TLS response from VK, so its
|
||||||
|
// signature is not re-verified here; the claim is only a fallback for a response that
|
||||||
|
// omits an explicit user_id.
|
||||||
|
func subjectFromIDToken(idToken string) string {
|
||||||
|
parts := strings.Split(idToken, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var claims struct {
|
||||||
|
Sub numericID `json:"sub"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s := string(claims.Sub); s != "0" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package vkid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testExchanger builds an Exchanger pointed at a test server.
|
||||||
|
func testExchanger(endpoint string) *Exchanger {
|
||||||
|
return &Exchanger{
|
||||||
|
appID: "app-1",
|
||||||
|
clientSecret: "secret-1",
|
||||||
|
redirectURI: "https://example.test/app/",
|
||||||
|
endpoint: endpoint,
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeSuccessSendsConfidentialPKCE(t *testing.T) {
|
||||||
|
var gotForm url.Values
|
||||||
|
var gotContentType string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotContentType = r.Header.Get("Content-Type")
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
gotForm, _ = url.ParseQuery(string(body))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"user_id":"12345","access_token":"a","id_token":"h.e.s"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
id, err := testExchanger(srv.URL).Exchange(context.Background(), "the-code", "dev-9", "verifier-xyz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exchange: %v", err)
|
||||||
|
}
|
||||||
|
if id.ExternalID != "12345" {
|
||||||
|
t.Fatalf("ExternalID = %q, want 12345", id.ExternalID)
|
||||||
|
}
|
||||||
|
if gotContentType != "application/x-www-form-urlencoded" {
|
||||||
|
t.Errorf("Content-Type = %q", gotContentType)
|
||||||
|
}
|
||||||
|
// The confidential exchange must carry the PKCE inputs and the app credentials.
|
||||||
|
want := map[string]string{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": "the-code",
|
||||||
|
"code_verifier": "verifier-xyz",
|
||||||
|
"device_id": "dev-9",
|
||||||
|
"client_id": "app-1",
|
||||||
|
"client_secret": "secret-1",
|
||||||
|
"redirect_uri": "https://example.test/app/",
|
||||||
|
}
|
||||||
|
for k, v := range want {
|
||||||
|
if gotForm.Get(k) != v {
|
||||||
|
t.Errorf("form[%s] = %q, want %q", k, gotForm.Get(k), v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeAcceptsNumericUserID(t *testing.T) {
|
||||||
|
// VK returns user_id as a bare JSON number in some responses; it must parse too.
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, `{"user_id":1234567890,"access_token":"a"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
id, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exchange: %v", err)
|
||||||
|
}
|
||||||
|
if id.ExternalID != "1234567890" {
|
||||||
|
t.Fatalf("ExternalID = %q, want 1234567890", id.ExternalID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeFallsBackToIDTokenSub(t *testing.T) {
|
||||||
|
sub := "987654321"
|
||||||
|
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"` + sub + `"}`))
|
||||||
|
idToken := "header." + payload + ".sig"
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, `{"access_token":"a","id_token":"`+idToken+`"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
id, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exchange: %v", err)
|
||||||
|
}
|
||||||
|
if id.ExternalID != sub {
|
||||||
|
t.Fatalf("ExternalID = %q, want %q (from id_token sub)", id.ExternalID, sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeRejectedIsErrInvalid(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = io.WriteString(w, `{"error":"invalid_grant","error_description":"code expired"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
|
||||||
|
if !errors.Is(err, ErrInvalid) {
|
||||||
|
t.Fatalf("err = %v, want ErrInvalid", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeEmptyInputsFailFast(t *testing.T) {
|
||||||
|
// Missing PKCE inputs are rejected without any network call.
|
||||||
|
ex := testExchanger("http://127.0.0.1:0/never")
|
||||||
|
for _, args := range [][3]string{{"", "d", "v"}, {"c", "", "v"}, {"c", "d", ""}} {
|
||||||
|
if _, err := ex.Exchange(context.Background(), args[0], args[1], args[2]); !errors.Is(err, ErrInvalid) {
|
||||||
|
t.Errorf("Exchange%v err = %v, want ErrInvalid", args, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeTransportErrorIsNotErrInvalid(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
srv.Close() // closed listener → connection refused
|
||||||
|
_, err := testExchanger(srv.URL).Exchange(context.Background(), "c", "d", "v")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want a transport error")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrInvalid) {
|
||||||
|
t.Fatalf("transport error must not be ErrInvalid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -501,6 +501,17 @@ table LinkTelegramRequest {
|
|||||||
data:string;
|
data:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinkVKRequest carries a VK ID web authorization for attaching a VK identity to the
|
||||||
|
// current account. The fields are the PKCE code-exchange inputs the frontend obtains
|
||||||
|
// from the VK ID SDK (One Tap): the authorization code, the device id issued with it,
|
||||||
|
// and the PKCE code verifier. The gateway completes the confidential code exchange
|
||||||
|
// server-side (under the app's protected key) to obtain the trusted vk user id.
|
||||||
|
table LinkVKRequest {
|
||||||
|
code:string;
|
||||||
|
device_id:string;
|
||||||
|
code_verifier:string;
|
||||||
|
}
|
||||||
|
|
||||||
// LinkUnlinkRequest detaches a platform identity (kind = "telegram" | "vk") from the
|
// LinkUnlinkRequest detaches a platform identity (kind = "telegram" | "vk") from the
|
||||||
// caller's account; email is never unlinked (it is changed).
|
// caller's account; email is never unlinked (it is changed).
|
||||||
table LinkUnlinkRequest {
|
table LinkUnlinkRequest {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
+234
@@ -2,6 +2,240 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<!-- Boot capability guard. Runs before the deferred ES module, in plain ES5 so it survives even
|
||||||
|
on an engine too old to parse the bundle. Three jobs, as early as possible:
|
||||||
|
1. Hard gate — if an UNPOLYFILLABLE essential is missing (BigInt for the 64-bit
|
||||||
|
FlatBuffers wire decode, Proxy for Svelte 5 runes) the app cannot run at all: show the
|
||||||
|
unsupported-engine screen (an old Android System WebView, e.g. Chromium 66, is the case
|
||||||
|
this guards) instead of a white screen.
|
||||||
|
2. Soft gate — if only polyfillable es2020+ globals are missing (globalThis,
|
||||||
|
structuredClone, Array.at, …) pull core-js (emitted as polyfills.js) BEFORE the module
|
||||||
|
runs. document.write is deliberate: the only way to inject a parser-blocking <script>
|
||||||
|
guaranteed to run ahead of a deferred module; its argument is a static literal, so no
|
||||||
|
injection surface.
|
||||||
|
3. Reactive net — record any uncaught error/rejection during boot; if the app has not
|
||||||
|
signalled window.__booted within a grace period AND an error fired, show the same
|
||||||
|
screen with the captured cause (covers a bundle that fails to parse, or an unforeseen
|
||||||
|
incompatibility). __booted is set in App.svelte once bootstrap resolves.
|
||||||
|
The screen has a "Diagnostic information" view (engine + feature table + reason + version)
|
||||||
|
with a Copy button. On a capable engine nothing here renders, so neither the bundle-size
|
||||||
|
budget nor the mock e2e is affected. -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var nav = navigator;
|
||||||
|
var ua = nav.userAgent || '';
|
||||||
|
var VERSION = '__BOOT_VERSION__'; // replaced at build (vite.config injectBootVersion)
|
||||||
|
var RU = (nav.language || '').toLowerCase().indexOf('ru') === 0;
|
||||||
|
var MINIAPP = /\/(?:telegram|vk)\//.test(location.pathname);
|
||||||
|
var WEBURL = location.protocol + '//' + location.host + '/app/';
|
||||||
|
|
||||||
|
// [label, test, Chrome version it landed in, hard?] — hard = required and unpolyfillable.
|
||||||
|
function has(fn) {
|
||||||
|
try {
|
||||||
|
return !!fn();
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var probes = [
|
||||||
|
['BigInt', function () { return typeof BigInt !== 'undefined'; }, 67, true],
|
||||||
|
['Proxy', function () { return typeof Proxy !== 'undefined'; }, 49, true],
|
||||||
|
['globalThis', function () { return typeof globalThis !== 'undefined'; }, 71, false],
|
||||||
|
['structuredClone', function () { return typeof structuredClone === 'function'; }, 98, false],
|
||||||
|
['Array.prototype.at', function () { return typeof [].at === 'function'; }, 92, false],
|
||||||
|
['Array.prototype.findLast', function () { return typeof [].findLast === 'function'; }, 97, false],
|
||||||
|
['Object.hasOwn', function () { return typeof Object.hasOwn === 'function'; }, 93, false],
|
||||||
|
['Object.fromEntries', function () { return typeof Object.fromEntries === 'function'; }, 73, false],
|
||||||
|
['Promise.allSettled', function () { return !!Promise.allSettled; }, 76, false],
|
||||||
|
['Promise.any', function () { return !!Promise.any; }, 85, false],
|
||||||
|
['WeakRef', function () { return typeof WeakRef !== 'undefined'; }, 84, false],
|
||||||
|
['queueMicrotask', function () { return typeof queueMicrotask === 'function'; }, 71, false]
|
||||||
|
];
|
||||||
|
var results = [];
|
||||||
|
var hardMissing = [];
|
||||||
|
var softMissing = false;
|
||||||
|
for (var i = 0; i < probes.length; i++) {
|
||||||
|
var ok = has(probes[i][1]);
|
||||||
|
results.push({ name: probes[i][0], ok: ok, chrome: probes[i][2], hard: probes[i][3] });
|
||||||
|
if (!ok) {
|
||||||
|
if (probes[i][3]) hardMissing.push(probes[i][0]);
|
||||||
|
else softMissing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The diagnostic report (built on demand, behind the button).
|
||||||
|
function diag(reason) {
|
||||||
|
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
|
||||||
|
var wv = /;\s*wv\)/.test(ua) || /\bwv\b/.test(ua);
|
||||||
|
var out = [];
|
||||||
|
out.push('reason : ' + reason);
|
||||||
|
out.push('app : ' + VERSION);
|
||||||
|
out.push('chromium : ' + (cm ? cm[1] : 'n/a') + (wv ? ' (Android WebView)' : ''));
|
||||||
|
out.push('userAgent : ' + ua);
|
||||||
|
out.push('url : ' + location.href);
|
||||||
|
out.push('viewport : ' + window.innerWidth + 'x' + window.innerHeight + ' @' + (window.devicePixelRatio || 1));
|
||||||
|
out.push('lang : ' + (nav.language || '?') + ' online: ' + nav.onLine);
|
||||||
|
out.push('');
|
||||||
|
out.push('features:');
|
||||||
|
for (var k = 0; k < results.length; k++) {
|
||||||
|
var r = results[k];
|
||||||
|
out.push(' ' + (r.ok ? 'OK' : 'NO') + ' ' + r.name + ' (' + (r.hard ? 'required' : 'polyfilled') + ', Chrome ' + r.chrome + ')');
|
||||||
|
}
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, style, text) {
|
||||||
|
var e = document.createElement(tag);
|
||||||
|
if (style) e.setAttribute('style', style);
|
||||||
|
if (text != null) e.appendChild(document.createTextNode(text));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
function btn(bg, fg) {
|
||||||
|
return 'display:inline-block;margin:0 10px 10px 0;padding:11px 18px;border:0;border-radius:8px;' +
|
||||||
|
'font:600 15px/1 inherit;cursor:pointer;color:' + fg + ';background:' + bg + ';';
|
||||||
|
}
|
||||||
|
|
||||||
|
var shown = false;
|
||||||
|
// Fire-and-forget beacon so the gateway can count who hits this screen (Grafana). Deduped
|
||||||
|
// in localStorage by app version + reason + Chromium, so a user reopening the app ten times
|
||||||
|
// is one report. sendBeacon (Chrome 39+, present on the engines that reach here) with a
|
||||||
|
// fetch fallback; both are best-effort and never block or throw.
|
||||||
|
function beacon(code) {
|
||||||
|
try {
|
||||||
|
var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua);
|
||||||
|
var chromium = cm ? cm[1] : '';
|
||||||
|
var sig = VERSION + '|' + code + '|' + chromium;
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('scrabble_unsupp') === sig) return;
|
||||||
|
} catch (e) {}
|
||||||
|
var payload = JSON.stringify({ reason: code, chromium: chromium, version: VERSION, ua: ua });
|
||||||
|
var sent = false;
|
||||||
|
try {
|
||||||
|
if (nav.sendBeacon) sent = nav.sendBeacon('/telemetry/unsupported', new Blob([payload], { type: 'application/json' }));
|
||||||
|
} catch (e) {}
|
||||||
|
if (!sent && typeof fetch === 'function') {
|
||||||
|
try {
|
||||||
|
fetch('/telemetry/unsupported', { method: 'POST', body: payload, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(function () {});
|
||||||
|
sent = true;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (sent) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('scrabble_unsupp', sig);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
function show(reason, code) {
|
||||||
|
if (shown) return;
|
||||||
|
shown = true;
|
||||||
|
beacon(code);
|
||||||
|
var root = el('div', 'position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483647;overflow:auto;' +
|
||||||
|
'-webkit-overflow-scrolling:touch;background:#0f1115;color:#e8eaed;box-sizing:border-box;padding:24px;' +
|
||||||
|
'font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;');
|
||||||
|
var wrap = el('div', 'max-width:520px;margin:0 auto;');
|
||||||
|
root.appendChild(wrap);
|
||||||
|
|
||||||
|
var msg = el('div', null);
|
||||||
|
msg.appendChild(el('div', 'font-size:22px;font-weight:700;margin-bottom:14px;', 'Эрудит'));
|
||||||
|
msg.appendChild(el('p', 'margin:0 0 14px;', RU
|
||||||
|
? 'На вашей версии операционной системы или браузера приложение не сможет работать.'
|
||||||
|
: "This app can't run on your device's operating system or browser version."));
|
||||||
|
if (MINIAPP) {
|
||||||
|
msg.appendChild(el('p', 'margin:0 0 6px;', RU
|
||||||
|
? 'Откройте веб-версию в обычном браузере (Chrome, Firefox):'
|
||||||
|
: 'Open the web version in a regular browser (Chrome, Firefox):'));
|
||||||
|
var a = el('a', 'color:#8ab4ff;word-break:break-all;', WEBURL);
|
||||||
|
a.setAttribute('href', WEBURL);
|
||||||
|
a.setAttribute('target', '_blank');
|
||||||
|
a.setAttribute('rel', 'noopener');
|
||||||
|
var lp = el('p', 'margin:0 0 18px;');
|
||||||
|
lp.appendChild(a);
|
||||||
|
msg.appendChild(lp);
|
||||||
|
}
|
||||||
|
var infoBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Диагностическая информация' : 'Diagnostic information');
|
||||||
|
msg.appendChild(infoBtn);
|
||||||
|
wrap.appendChild(msg);
|
||||||
|
|
||||||
|
var dv = el('div', 'display:none;');
|
||||||
|
var pre = el('pre', 'white-space:pre-wrap;word-break:break-word;background:#0b0d11;border-radius:8px;padding:12px;' +
|
||||||
|
'font:12.5px/1.45 ui-monospace,Menlo,Consolas,monospace;overflow:auto;', diag(reason));
|
||||||
|
dv.appendChild(pre);
|
||||||
|
var copyBtn = el('button', btn('#8ab4ff', '#0b0d11'), RU ? 'Копировать' : 'Copy');
|
||||||
|
var backBtn = el('button', btn('#2a2f3a', '#e8eaed'), RU ? 'Назад' : 'Back');
|
||||||
|
var row = el('div', 'margin-top:12px;');
|
||||||
|
row.appendChild(copyBtn);
|
||||||
|
row.appendChild(backBtn);
|
||||||
|
dv.appendChild(row);
|
||||||
|
wrap.appendChild(dv);
|
||||||
|
|
||||||
|
infoBtn.onclick = function () {
|
||||||
|
msg.style.display = 'none';
|
||||||
|
dv.style.display = 'block';
|
||||||
|
};
|
||||||
|
backBtn.onclick = function () {
|
||||||
|
dv.style.display = 'none';
|
||||||
|
msg.style.display = 'block';
|
||||||
|
};
|
||||||
|
copyBtn.onclick = function () {
|
||||||
|
var txt = diag(reason);
|
||||||
|
try {
|
||||||
|
if (nav.clipboard && nav.clipboard.writeText) {
|
||||||
|
nav.clipboard.writeText(txt);
|
||||||
|
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = txt;
|
||||||
|
ta.setAttribute('style', 'position:fixed;left:-9999px;top:0;');
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
copyBtn.firstChild.nodeValue = RU ? 'Скопировано' : 'Copied';
|
||||||
|
} catch (e2) {
|
||||||
|
copyBtn.firstChild.nodeValue = RU ? 'Выделите и скопируйте текст' : 'Select and copy the text';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
document.body.insertBefore(root, document.body.firstChild);
|
||||||
|
}
|
||||||
|
if (document.body) mount();
|
||||||
|
else document.addEventListener('DOMContentLoaded', mount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 + 2: the gate.
|
||||||
|
if (hardMissing.length) {
|
||||||
|
show((RU ? 'нет: ' : 'missing: ') + hardMissing.join(', '), hardMissing.indexOf('BigInt') >= 0 ? 'no_bigint' : 'no_proxy');
|
||||||
|
} else if (softMissing) {
|
||||||
|
document.write('<script src="polyfills.js"><\/script>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3: the reactive net for an unforeseen boot failure.
|
||||||
|
var bootErr = null;
|
||||||
|
function note(m) {
|
||||||
|
if (!bootErr) bootErr = m;
|
||||||
|
}
|
||||||
|
window.onerror = function (m, s, l, c, e) {
|
||||||
|
note(String(m) + (e && e.stack ? '\n' + e.stack : s ? ' @ ' + s + ':' + l : ''));
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
window.addEventListener('error', function (e) {
|
||||||
|
if (e && e.message) note(e.message + (e.filename ? ' @ ' + e.filename + ':' + e.lineno : ''));
|
||||||
|
}, true);
|
||||||
|
window.addEventListener('unhandledrejection', function (e) {
|
||||||
|
var r = e && e.reason;
|
||||||
|
note(r && (r.stack || r.message) ? r.stack || r.message : String(r));
|
||||||
|
});
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!window.__booted && bootErr && !shown) show((RU ? 'ошибка запуска: ' : 'boot error: ') + bootErr, 'boot_error');
|
||||||
|
}, 8000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<!-- The SPA shell is an empty client-rendered document behind the public landing — keep it
|
<!-- The SPA shell is an empty client-rendered document behind the public landing — keep it
|
||||||
out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can
|
out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can
|
||||||
reach this tag). -->
|
reach this tag). -->
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
"core-js-bundle": "^3.49.0",
|
||||||
"svelte": "^5.15.0",
|
"svelte": "^5.15.0",
|
||||||
"svelte-check": "^4.1.0",
|
"svelte-check": "^4.1.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
|
|||||||
Generated
+8
@@ -36,6 +36,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.10.0
|
specifier: ^22.10.0
|
||||||
version: 22.19.19
|
version: 22.19.19
|
||||||
|
core-js-bundle:
|
||||||
|
specifier: ^3.49.0
|
||||||
|
version: 3.49.0
|
||||||
svelte:
|
svelte:
|
||||||
specifier: ^5.15.0
|
specifier: ^5.15.0
|
||||||
version: 5.56.0
|
version: 5.56.0
|
||||||
@@ -508,6 +511,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
core-js-bundle@3.49.0:
|
||||||
|
resolution: {integrity: sha512-WXc7oOsePN3aKFOJVG5zQdi+h/Jm2W0WIPYvRc4IG3vkNcbC2w6LlSzTmnhOl6N1xmOJEzCSNieX3mwF+3zBGw==}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1132,6 +1138,8 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
core-js-bundle@3.49.0: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
# pnpm 11 records build-script approval here. esbuild's postinstall materialises
|
# pnpm 11 records build-script approval here. esbuild's postinstall materialises
|
||||||
# its CLI shim; the platform binary itself ships as an optional dependency.
|
# its CLI shim; the platform binary itself ships as an optional dependency.
|
||||||
|
# core-js-bundle ships a prebuilt minified.js (we only read that file at build time,
|
||||||
|
# via vite.config emitPolyfills) and its install script is just a funding banner, so
|
||||||
|
# it is denied — nothing to build.
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
core-js-bundle: false
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
|||||||
+8
-1
@@ -9,6 +9,7 @@
|
|||||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||||
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||||
import Coachmark from './components/Coachmark.svelte';
|
import Coachmark from './components/Coachmark.svelte';
|
||||||
|
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
|
||||||
import DebugPanel from './components/DebugPanel.svelte';
|
import DebugPanel from './components/DebugPanel.svelte';
|
||||||
import Login from './screens/Login.svelte';
|
import Login from './screens/Login.svelte';
|
||||||
import Lobby from './screens/Lobby.svelte';
|
import Lobby from './screens/Lobby.svelte';
|
||||||
@@ -25,7 +26,12 @@
|
|||||||
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
|
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void bootstrap();
|
// Tell the index.html boot guard that startup completed, so its reactive net does not raise the
|
||||||
|
// unsupported-engine screen on a healthy boot — it only fires when an uncaught error occurred
|
||||||
|
// AND this flag never sets (a bundle that failed to parse, or an unforeseen incompatibility).
|
||||||
|
void bootstrap().then(() => {
|
||||||
|
(window as unknown as { __booted?: boolean }).__booted = true;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The lobby is the cold-start landing (an empty hash and Telegram launch params both parse
|
// The lobby is the cold-start landing (an empty hash and Telegram launch params both parse
|
||||||
@@ -128,6 +134,7 @@
|
|||||||
<StaleInviteModal />
|
<StaleInviteModal />
|
||||||
<WelcomeRedeemModal />
|
<WelcomeRedeemModal />
|
||||||
<Coachmark />
|
<Coachmark />
|
||||||
|
<MaintenanceOverlay />
|
||||||
|
|
||||||
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
||||||
<Splash />
|
<Splash />
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Non-dismissable maintenance cover. Shown while the edge is in a planned deploy window
|
||||||
|
// (maintenance.svelte.ts, raised from a caddy 503 carrying X-Scrabble-Maintenance). It has
|
||||||
|
// no close affordance — an in-session user cannot dismiss it — but the store's poll lifts
|
||||||
|
// it automatically the moment the gateway answers again; the "retry" button just forces an
|
||||||
|
// immediate re-check. Mirrors the static caddy maintenance page (deploy/caddy/maintenance.html)
|
||||||
|
// so a fresh load and an in-session user see the same thing.
|
||||||
|
import { maintenance, retryNow } from '../lib/maintenance.svelte';
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if maintenance.active}
|
||||||
|
<div class="scrim" role="alertdialog" aria-modal="true" aria-labelledby="maint-title" aria-describedby="maint-body">
|
||||||
|
<div class="card">
|
||||||
|
<div class="tile" aria-hidden="true">Э</div>
|
||||||
|
<h1 id="maint-title">{t('maintenance.title')}</h1>
|
||||||
|
<p id="maint-body">{t('maintenance.body')}</p>
|
||||||
|
<button type="button" onclick={retryNow}>{t('maintenance.retry')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
/* Above the game (z 60) and toasts (z 50) — a planned window covers everything the user
|
||||||
|
could otherwise interact with; below the dev DebugPanel (z 10000). */
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
max-width: 22rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
/* Mirrors a placed board tile (as Splash.svelte / the caddy page do). */
|
||||||
|
.tile {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--tile-bg);
|
||||||
|
color: var(--tile-text);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 -2px 0 var(--tile-edge),
|
||||||
|
2px 0 3px -1px rgba(0, 0, 0, 0.4);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.55rem 1.4rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -479,6 +479,11 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 5%;
|
top: 5%;
|
||||||
left: 8%;
|
left: 8%;
|
||||||
|
/* Chrome < 105 has no container-query units: a dropped `cqw` would inherit the .cell
|
||||||
|
`font-size: 0` and the glyph would vanish. Fall back to a viewport-relative size that tracks
|
||||||
|
the board (≈ the viewport-fitted query container) and the zoom (--z), so old Android System
|
||||||
|
WebViews still draw the tile glyphs. Chrome 105+ takes the exact `cqw` line below. */
|
||||||
|
font-size: calc(4.2vmin * var(--z, 1));
|
||||||
font-size: 4.2cqw;
|
font-size: 4.2cqw;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -487,12 +492,14 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 5%;
|
right: 5%;
|
||||||
bottom: 3%;
|
bottom: 3%;
|
||||||
|
font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||||
font-size: 2.4cqw;
|
font-size: 2.4cqw;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
|
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
|
||||||
its ink centred on the same line as a neighbouring tile's value digit. */
|
its ink centred on the same line as a neighbouring tile's value digit. */
|
||||||
.blankmark {
|
.blankmark {
|
||||||
|
font-size: calc(2.8vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||||
font-size: 2.8cqw;
|
font-size: 2.8cqw;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -501,6 +508,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
font-size: calc(3.6vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||||
font-size: 3.6cqw;
|
font-size: 3.6cqw;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
@@ -509,6 +517,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
font-size: calc(2.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||||
font-size: 2.7cqw;
|
font-size: 2.7cqw;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
@@ -526,10 +535,12 @@
|
|||||||
padding: 0 1px;
|
padding: 0 1px;
|
||||||
}
|
}
|
||||||
.bt {
|
.bt {
|
||||||
|
font-size: calc(1.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||||
font-size: 1.7cqw;
|
font-size: 1.7cqw;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.bb {
|
.bb {
|
||||||
|
font-size: calc(1.9vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
|
||||||
font-size: 1.9cqw;
|
font-size: 1.9cqw;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export { LinkEmailRequest } from './scrabblefb/link-email-request.js';
|
|||||||
export { LinkResult } from './scrabblefb/link-result.js';
|
export { LinkResult } from './scrabblefb/link-result.js';
|
||||||
export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js';
|
export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js';
|
||||||
export { LinkUnlinkRequest } from './scrabblefb/link-unlink-request.js';
|
export { LinkUnlinkRequest } from './scrabblefb/link-unlink-request.js';
|
||||||
|
export { LinkVKRequest } from './scrabblefb/link-vkrequest.js';
|
||||||
export { MatchFoundEvent } from './scrabblefb/match-found-event.js';
|
export { MatchFoundEvent } from './scrabblefb/match-found-event.js';
|
||||||
export { MatchResult } from './scrabblefb/match-result.js';
|
export { MatchResult } from './scrabblefb/match-result.js';
|
||||||
export { MoveRecord } from './scrabblefb/move-record.js';
|
export { MoveRecord } from './scrabblefb/move-record.js';
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class LinkVKRequest {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):LinkVKRequest {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsLinkVKRequest(bb:flatbuffers.ByteBuffer, obj?:LinkVKRequest):LinkVKRequest {
|
||||||
|
return (obj || new LinkVKRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsLinkVKRequest(bb:flatbuffers.ByteBuffer, obj?:LinkVKRequest):LinkVKRequest {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new LinkVKRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
code():string|null
|
||||||
|
code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
code(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceId():string|null
|
||||||
|
deviceId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
deviceId(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
codeVerifier():string|null
|
||||||
|
codeVerifier(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
codeVerifier(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startLinkVKRequest(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(0, codeOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addDeviceId(builder:flatbuffers.Builder, deviceIdOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(1, deviceIdOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addCodeVerifier(builder:flatbuffers.Builder, codeVerifierOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, codeVerifierOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endLinkVKRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createLinkVKRequest(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset, deviceIdOffset:flatbuffers.Offset, codeVerifierOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
LinkVKRequest.startLinkVKRequest(builder);
|
||||||
|
LinkVKRequest.addCode(builder, codeOffset);
|
||||||
|
LinkVKRequest.addDeviceId(builder, deviceIdOffset);
|
||||||
|
LinkVKRequest.addCodeVerifier(builder, codeVerifierOffset);
|
||||||
|
return LinkVKRequest.endLinkVKRequest(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
telegramCloudSet,
|
telegramCloudSet,
|
||||||
} from './telegram';
|
} from './telegram';
|
||||||
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
|
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
|
||||||
|
import { pendingVKLink, type VKLinkCallback } from './vkid';
|
||||||
import { haptic } from './haptics';
|
import { haptic } from './haptics';
|
||||||
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||||
import { parseStartParam } from './deeplink';
|
import { parseStartParam } from './deeplink';
|
||||||
@@ -96,6 +97,10 @@ export const app = $state<{
|
|||||||
/** Set once the account has been deleted: App.svelte shows the terminal "account deleted"
|
/** Set once the account has been deleted: App.svelte shows the terminal "account deleted"
|
||||||
* screen and all push/poll is stopped. Reopening the app just creates a fresh account. */
|
* screen and all push/poll is stopped. Reopening the app just creates a fresh account. */
|
||||||
accountDeleted: boolean;
|
accountDeleted: boolean;
|
||||||
|
/** A VK ID web-link authorization callback captured on boot (see lib/vkid): the browser
|
||||||
|
* returned from VK with an auth code. Profile consumes it on mount to finish the link or
|
||||||
|
* merge (a full-page redirect loses the route, so boot routes here). Null on a normal load. */
|
||||||
|
vkLinkPending: VKLinkCallback | null;
|
||||||
toast: Toast | null;
|
toast: Toast | null;
|
||||||
lastEvent: PushEvent | null;
|
lastEvent: PushEvent | null;
|
||||||
theme: ThemePref;
|
theme: ThemePref;
|
||||||
@@ -145,6 +150,7 @@ export const app = $state<{
|
|||||||
profile: null,
|
profile: null,
|
||||||
blocked: null,
|
blocked: null,
|
||||||
accountDeleted: false,
|
accountDeleted: false,
|
||||||
|
vkLinkPending: null,
|
||||||
toast: null,
|
toast: null,
|
||||||
lastEvent: null,
|
lastEvent: null,
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
@@ -778,9 +784,19 @@ export async function bootstrap(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saved = await loadSession();
|
const saved = await loadSession();
|
||||||
|
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
|
||||||
|
// from VK; capture and clear it here (it needs the restored session to link against).
|
||||||
|
const vkcb = pendingVKLink();
|
||||||
if (saved) {
|
if (saved) {
|
||||||
await adoptSession(saved);
|
await adoptSession(saved);
|
||||||
if (router.route.name === 'login') navigate('/');
|
if (vkcb) {
|
||||||
|
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
|
||||||
|
// finishes the link or merge on mount.
|
||||||
|
app.vkLinkPending = vkcb;
|
||||||
|
navigate('/profile');
|
||||||
|
} else if (router.route.name === 'login') {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
|
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,10 @@ export interface GatewayClient {
|
|||||||
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
|
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
|
||||||
linkTelegram(data: string): Promise<LinkResult>;
|
linkTelegram(data: string): Promise<LinkResult>;
|
||||||
linkTelegramMerge(data: string): Promise<LinkResult>;
|
linkTelegramMerge(data: string): Promise<LinkResult>;
|
||||||
|
/** Link a VK identity from the web: the args are the PKCE outputs of the VK ID
|
||||||
|
* authorization callback, exchanged for the trusted vk id on the gateway. */
|
||||||
|
linkVK(code: string, deviceId: string, codeVerifier: string): Promise<LinkResult>;
|
||||||
|
linkVKMerge(code: string, deviceId: string, codeVerifier: string): Promise<LinkResult>;
|
||||||
/** Detach a platform identity (kind 'telegram' | 'vk') from the account; email is never
|
/** Detach a platform identity (kind 'telegram' | 'vk') from the account; email is never
|
||||||
* unlinked. Returns the refreshed link result (status 'unlinked'). */
|
* unlinked. Returns the refreshed link result (status 'unlinked'). */
|
||||||
linkUnlink(kind: string): Promise<LinkResult>;
|
linkUnlink(kind: string): Promise<LinkResult>;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
decodeDeleteRequestResult,
|
decodeDeleteRequestResult,
|
||||||
encodeGuestLogin,
|
encodeGuestLogin,
|
||||||
encodeLinkUnlink,
|
encodeLinkUnlink,
|
||||||
|
encodeLinkVK,
|
||||||
encodeStateRequest,
|
encodeStateRequest,
|
||||||
encodeSubmitPlay,
|
encodeSubmitPlay,
|
||||||
encodeTarget,
|
encodeTarget,
|
||||||
@@ -466,6 +467,13 @@ describe('codec', () => {
|
|||||||
expect(r.kind()).toBe('telegram');
|
expect(r.kind()).toBe('telegram');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('encodes a VK link request carrying the PKCE code-exchange inputs', () => {
|
||||||
|
const r = fb.LinkVKRequest.getRootAsLinkVKRequest(new ByteBuffer(encodeLinkVK('the-code', 'dev-1', 'verifier-1')));
|
||||||
|
expect(r.code()).toBe('the-code');
|
||||||
|
expect(r.deviceId()).toBe('dev-1');
|
||||||
|
expect(r.codeVerifier()).toBe('verifier-1');
|
||||||
|
});
|
||||||
|
|
||||||
it('round-trips the account-delete confirm + request-result wire', () => {
|
it('round-trips the account-delete confirm + request-result wire', () => {
|
||||||
const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm(
|
const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm(
|
||||||
new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')),
|
new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')),
|
||||||
|
|||||||
@@ -739,6 +739,18 @@ export function encodeLinkUnlink(kind: string): Uint8Array {
|
|||||||
return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b));
|
return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function encodeLinkVK(code: string, deviceId: string, codeVerifier: string): Uint8Array {
|
||||||
|
const b = new Builder(256);
|
||||||
|
const c = b.createString(code);
|
||||||
|
const d = b.createString(deviceId);
|
||||||
|
const v = b.createString(codeVerifier);
|
||||||
|
fb.LinkVKRequest.startLinkVKRequest(b);
|
||||||
|
fb.LinkVKRequest.addCode(b, c);
|
||||||
|
fb.LinkVKRequest.addDeviceId(b, d);
|
||||||
|
fb.LinkVKRequest.addCodeVerifier(b, v);
|
||||||
|
return finish(b, fb.LinkVKRequest.endLinkVKRequest(b));
|
||||||
|
}
|
||||||
|
|
||||||
export function encodeAccountDeleteConfirm(code: string, phrase: string): Uint8Array {
|
export function encodeAccountDeleteConfirm(code: string, phrase: string): Uint8Array {
|
||||||
const b = new Builder(64);
|
const b = new Builder(64);
|
||||||
const c = b.createString(code);
|
const c = b.createString(code);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { GatewayClient } from './client';
|
|||||||
import { MockGateway } from './mock/client';
|
import { MockGateway } from './mock/client';
|
||||||
import { createTransport } from './transport';
|
import { createTransport } from './transport';
|
||||||
import { reportOffline, reportOnline } from './connection.svelte';
|
import { reportOffline, reportOnline } from './connection.svelte';
|
||||||
|
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
|
||||||
|
|
||||||
const isMock = import.meta.env.MODE === 'mock';
|
const isMock = import.meta.env.MODE === 'mock';
|
||||||
|
|
||||||
@@ -20,6 +21,15 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
offline: reportOffline,
|
offline: reportOffline,
|
||||||
online: reportOnline,
|
online: reportOnline,
|
||||||
};
|
};
|
||||||
|
// The mock never produces a real 503, so the e2e drives the maintenance overlay directly
|
||||||
|
// (the store's poll is inert in mock — no probe is registered — so `off` clears it).
|
||||||
|
(
|
||||||
|
window as unknown as { __maint?: { on(retryAfterMs?: number): void; off(): void; recover(): void } }
|
||||||
|
).__maint = {
|
||||||
|
on: (retryAfterMs = 15000) => reportMaintenance(retryAfterMs),
|
||||||
|
off: clearMaintenance,
|
||||||
|
recover: maintenanceRecovered,
|
||||||
|
};
|
||||||
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
||||||
// attaches a robot on a timer).
|
// attaches a robot on a timer).
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export const en = {
|
|||||||
'app.brand': 'Erudit',
|
'app.brand': 'Erudit',
|
||||||
'dict.loading': 'Loading…',
|
'dict.loading': 'Loading…',
|
||||||
'connection.connecting': 'Connecting…',
|
'connection.connecting': 'Connecting…',
|
||||||
|
'maintenance.title': 'Under maintenance',
|
||||||
|
'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
|
||||||
|
'maintenance.retry': 'Try again',
|
||||||
|
|
||||||
'blocked.title': 'Account blocked',
|
'blocked.title': 'Account blocked',
|
||||||
'blocked.permanent': 'Your account is blocked.',
|
'blocked.permanent': 'Your account is blocked.',
|
||||||
@@ -173,6 +176,7 @@ export const en = {
|
|||||||
'profile.guestLocked': 'Sign in with email to manage your profile.',
|
'profile.guestLocked': 'Sign in with email to manage your profile.',
|
||||||
'profile.linkAccount': 'Link an account',
|
'profile.linkAccount': 'Link an account',
|
||||||
'profile.linkTelegram': 'Link Telegram',
|
'profile.linkTelegram': 'Link Telegram',
|
||||||
|
'profile.linkVK': 'Link VK',
|
||||||
'profile.linked': 'Account linked.',
|
'profile.linked': 'Account linked.',
|
||||||
'profile.merged': 'Accounts merged.',
|
'profile.merged': 'Accounts merged.',
|
||||||
'profile.mergeTitle': 'Merge accounts?',
|
'profile.mergeTitle': 'Merge accounts?',
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'app.brand': 'Эрудит',
|
'app.brand': 'Эрудит',
|
||||||
'dict.loading': 'Загрузка…',
|
'dict.loading': 'Загрузка…',
|
||||||
'connection.connecting': 'Подключение…',
|
'connection.connecting': 'Подключение…',
|
||||||
|
'maintenance.title': 'Технические работы',
|
||||||
|
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
|
||||||
|
'maintenance.retry': 'Повторить',
|
||||||
|
|
||||||
'blocked.title': 'Учётная запись заблокирована',
|
'blocked.title': 'Учётная запись заблокирована',
|
||||||
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
||||||
@@ -173,6 +176,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
|
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
|
||||||
'profile.linkAccount': 'Привязать аккаунт',
|
'profile.linkAccount': 'Привязать аккаунт',
|
||||||
'profile.linkTelegram': 'Привязать Telegram',
|
'profile.linkTelegram': 'Привязать Telegram',
|
||||||
|
'profile.linkVK': 'Привязать VK',
|
||||||
'profile.linked': 'Аккаунт привязан.',
|
'profile.linked': 'Аккаунт привязан.',
|
||||||
'profile.merged': 'Аккаунты объединены.',
|
'profile.merged': 'Аккаунты объединены.',
|
||||||
'profile.mergeTitle': 'Объединить аккаунты?',
|
'profile.mergeTitle': 'Объединить аккаунты?',
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Global maintenance signal + self-clearing poll. `active` is true while the edge is in a
|
||||||
|
// planned deploy window (a caddy 503 carried `X-Scrabble-Maintenance`; see maintenance.ts).
|
||||||
|
// A non-dismissable overlay (MaintenanceOverlay.svelte) covers the app while active, and a
|
||||||
|
// cheap read is retried on a capped-backoff cadence (mirroring connection.svelte.ts) — the
|
||||||
|
// first success lifts the overlay, so it can never get stuck even with no other traffic.
|
||||||
|
// Mock mode never reports maintenance, so it simply stays inactive.
|
||||||
|
|
||||||
|
import { backoffMs } from './retry';
|
||||||
|
|
||||||
|
let active = $state(false);
|
||||||
|
let retryHintMs = $state(0);
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let attempt = 0;
|
||||||
|
let probing = false;
|
||||||
|
let probe: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
|
export const maintenance = {
|
||||||
|
/** active is true while the edge is in a planned maintenance window. */
|
||||||
|
get active(): boolean {
|
||||||
|
return active;
|
||||||
|
},
|
||||||
|
/** retryHintMs is the last Retry-After hint in milliseconds (0 when unknown). */
|
||||||
|
get retryHintMs(): number {
|
||||||
|
return retryHintMs;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** registerMaintenanceProbe installs the reachability read the poll fires while active
|
||||||
|
* (the transport wires a cheap authenticated read). */
|
||||||
|
export function registerMaintenanceProbe(fn: () => Promise<void>): void {
|
||||||
|
probe = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** reportMaintenance raises the overlay and starts the self-clearing poll. Idempotent: a
|
||||||
|
* repeat hit only refreshes the hint, it never restarts a running poll. */
|
||||||
|
export function reportMaintenance(retryAfterMs: number): void {
|
||||||
|
retryHintMs = retryAfterMs;
|
||||||
|
if (active) return;
|
||||||
|
active = true;
|
||||||
|
attempt = 0;
|
||||||
|
if (probe) schedulePoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** clearMaintenance lowers the overlay and stops the poll without reloading (a reset, or the
|
||||||
|
* e2e hook). Prod recovery goes through maintenanceRecovered instead. */
|
||||||
|
export function clearMaintenance(): void {
|
||||||
|
active = false;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** maintenanceRecovered runs when the edge answers again after a window we were showing.
|
||||||
|
* The deploy that ended the window may have shipped a client-incompatible change (a wire /
|
||||||
|
* schema bump), and the running bundle is the OLD one — so reload to pick up the fresh
|
||||||
|
* client instead of merely hiding the overlay. A no-op unless the overlay was up; the cover
|
||||||
|
* stays over the brief reload flash. In prod recovery is the only way the overlay clears. */
|
||||||
|
export function maintenanceRecovered(): void {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
if (typeof location !== 'undefined') location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** retryNow forces an immediate re-check (the overlay's button). It never closes the
|
||||||
|
* overlay itself — only a successful probe does. A no-op while a probe is in flight. */
|
||||||
|
export function retryNow(): void {
|
||||||
|
if (!active || !probe || probing) return;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
attempt = 0;
|
||||||
|
runProbe();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePoll(): void {
|
||||||
|
pollTimer = setTimeout(runProbe, backoffMs(++attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runProbe(): void {
|
||||||
|
pollTimer = null;
|
||||||
|
if (!active || !probe || probing) return;
|
||||||
|
probing = true;
|
||||||
|
probe().then(
|
||||||
|
() => {
|
||||||
|
probing = false;
|
||||||
|
maintenanceRecovered();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
probing = false;
|
||||||
|
if (active) schedulePoll();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Code, ConnectError } from '@connectrpc/connect';
|
||||||
|
import { maintenanceRetryMs, parseRetryAfterMs } from './maintenance';
|
||||||
|
|
||||||
|
// A raw ConnectError as connect-web builds it from a non-Connect HTTP 503: the response
|
||||||
|
// headers are preserved as `.metadata` (verified against @connectrpc/connect-web).
|
||||||
|
const err503 = (headers: Record<string, string>): ConnectError =>
|
||||||
|
new ConnectError('HTTP 503', Code.Unavailable, new Headers(headers));
|
||||||
|
|
||||||
|
describe('maintenanceRetryMs', () => {
|
||||||
|
it('detects the marker and parses Retry-After', () => {
|
||||||
|
expect(maintenanceRetryMs(err503({ 'x-scrabble-maintenance': '1', 'retry-after': '120' }))).toBe(120_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the header case-insensitively, defaulting the delay when Retry-After is absent', () => {
|
||||||
|
expect(maintenanceRetryMs(err503({ 'X-Scrabble-Maintenance': '1' }))).toBe(15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for a 503 without the marker (a plain transient failure)', () => {
|
||||||
|
expect(maintenanceRetryMs(err503({ 'retry-after': '30' }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-Connect errors', () => {
|
||||||
|
expect(maintenanceRetryMs(new Error('boom'))).toBeNull();
|
||||||
|
expect(maintenanceRetryMs(undefined)).toBeNull();
|
||||||
|
expect(maintenanceRetryMs('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRetryAfterMs', () => {
|
||||||
|
it('clamps into the 3s–120s band', () => {
|
||||||
|
expect(parseRetryAfterMs('120')).toBe(120_000);
|
||||||
|
expect(parseRetryAfterMs('1')).toBe(3_000); // floor
|
||||||
|
expect(parseRetryAfterMs('9999')).toBe(120_000); // ceiling
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default on absent / garbage / non-positive', () => {
|
||||||
|
expect(parseRetryAfterMs(null)).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs(undefined)).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs('soon')).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs('0')).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs('-5')).toBe(15_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Detection of a planned edge maintenance window. During a prod deploy roll the edge
|
||||||
|
// caddy returns HTTP 503 with an `X-Scrabble-Maintenance: 1` header (and a `Retry-After`)
|
||||||
|
// for every gated route. The SPA keys STRICTLY on that marker — not on any transport
|
||||||
|
// `unavailable`, which the "Connecting…" indicator already covers — so a planned window
|
||||||
|
// raises the maintenance overlay while an ordinary blip stays a soft reconnect.
|
||||||
|
//
|
||||||
|
// These helpers are pure (no DOM, no runes) so they unit-test in the node vitest env. The
|
||||||
|
// maintenance marker + Retry-After ride on the raw ConnectError's `metadata`, which
|
||||||
|
// toGatewayError (retry.ts) discards — so detection must run on the raw error.
|
||||||
|
|
||||||
|
import { ConnectError } from '@connectrpc/connect';
|
||||||
|
|
||||||
|
const DEFAULT_RETRY_MS = 15_000;
|
||||||
|
const MIN_RETRY_MS = 3_000;
|
||||||
|
const MAX_RETRY_MS = 120_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parseRetryAfterMs converts a `Retry-After` header (delta-seconds — the edge emits
|
||||||
|
* seconds, never an HTTP-date) into a millisecond hint clamped to a sane band. An absent
|
||||||
|
* or unparseable value falls back to a default.
|
||||||
|
*/
|
||||||
|
export function parseRetryAfterMs(header: string | null | undefined): number {
|
||||||
|
const secs = header == null ? NaN : Number(header);
|
||||||
|
if (!Number.isFinite(secs) || secs <= 0) return DEFAULT_RETRY_MS;
|
||||||
|
return Math.min(MAX_RETRY_MS, Math.max(MIN_RETRY_MS, Math.round(secs * 1000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* maintenanceRetryMs returns the maintenance `Retry-After` hint in milliseconds when the
|
||||||
|
* thrown transport error is the edge maintenance 503 (carries `X-Scrabble-Maintenance: 1`),
|
||||||
|
* or null for any other error. Header lookup is case-insensitive (Headers.get).
|
||||||
|
*/
|
||||||
|
export function maintenanceRetryMs(e: unknown): number | null {
|
||||||
|
if (!(e instanceof ConnectError)) return null;
|
||||||
|
if (e.metadata?.get('x-scrabble-maintenance') !== '1') return null;
|
||||||
|
return parseRetryAfterMs(e.metadata.get('retry-after'));
|
||||||
|
}
|
||||||
@@ -674,6 +674,16 @@ export class MockGateway implements GatewayClient {
|
|||||||
this.profile.telegramLinked = true;
|
this.profile.telegramLinked = true;
|
||||||
return { ...emptyLinked(), status: 'merged' };
|
return { ...emptyLinked(), status: 'merged' };
|
||||||
}
|
}
|
||||||
|
async linkVK(_code: string, _deviceId: string, _codeVerifier: string): Promise<LinkResult> {
|
||||||
|
this.profile.isGuest = false;
|
||||||
|
this.profile.vkLinked = true;
|
||||||
|
return emptyLinked();
|
||||||
|
}
|
||||||
|
async linkVKMerge(_code: string, _deviceId: string, _codeVerifier: string): Promise<LinkResult> {
|
||||||
|
this.profile.isGuest = false;
|
||||||
|
this.profile.vkLinked = true;
|
||||||
|
return { ...emptyLinked(), status: 'merged' };
|
||||||
|
}
|
||||||
async linkUnlink(kind: string): Promise<LinkResult> {
|
async linkUnlink(kind: string): Promise<LinkResult> {
|
||||||
if (kind === 'telegram') this.profile.telegramLinked = false;
|
if (kind === 'telegram') this.profile.telegramLinked = false;
|
||||||
if (kind === 'vk') this.profile.vkLinked = false;
|
if (kind === 'vk') this.profile.vkLinked = false;
|
||||||
|
|||||||
+30
-3
@@ -12,6 +12,8 @@ import { GatewayError, type GatewayClient } from './client';
|
|||||||
import * as codec from './codec';
|
import * as codec from './codec';
|
||||||
import { browserOffset } from './profileValidation';
|
import { browserOffset } from './profileValidation';
|
||||||
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
||||||
|
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
||||||
|
import { maintenanceRetryMs } from './maintenance';
|
||||||
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||||
|
|
||||||
const MAX_RETRIES = 6;
|
const MAX_RETRIES = 6;
|
||||||
@@ -28,10 +30,14 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
|
|
||||||
// The reachability probe the connection watcher fires while offline: a cheap authenticated read
|
// The reachability probe the connection watcher fires while offline: a cheap authenticated read
|
||||||
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
||||||
registerProbe(async () => {
|
const reachabilityProbe = async (): Promise<void> => {
|
||||||
if (!token) throw new Error('no session');
|
if (!token) throw new Error('no session');
|
||||||
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
||||||
});
|
};
|
||||||
|
registerProbe(reachabilityProbe);
|
||||||
|
// The maintenance overlay reuses the same cheap read to poll for the end of a deploy
|
||||||
|
// window; the first success lifts the overlay (maintenance.svelte.ts).
|
||||||
|
registerMaintenanceProbe(reachabilityProbe);
|
||||||
|
|
||||||
// exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a
|
// exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a
|
||||||
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
|
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
|
||||||
@@ -43,6 +49,14 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal });
|
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry
|
if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry
|
||||||
|
const maintMs = maintenanceRetryMs(e);
|
||||||
|
if (maintMs !== null) {
|
||||||
|
// A planned deploy window (the edge 503 carried X-Scrabble-Maintenance): raise the
|
||||||
|
// overlay and fail fast — its own poll drives recovery — instead of burning the
|
||||||
|
// retry budget on every read for the length of the window.
|
||||||
|
reportMaintenance(maintMs);
|
||||||
|
throw toGatewayError(e);
|
||||||
|
}
|
||||||
const err = toGatewayError(e);
|
const err = toGatewayError(e);
|
||||||
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
|
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
|
||||||
reportOffline();
|
reportOffline();
|
||||||
@@ -53,6 +67,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
reportOnline();
|
reportOnline();
|
||||||
|
// A read got through: if the maintenance overlay was up, the deploy window has ended —
|
||||||
|
// reload to pick up the (possibly incompatible) fresh client (maintenance.svelte.ts).
|
||||||
|
maintenanceRecovered();
|
||||||
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
|
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
|
||||||
return res.payload;
|
return res.payload;
|
||||||
}
|
}
|
||||||
@@ -268,6 +285,12 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async linkTelegramMerge(data) {
|
async linkTelegramMerge(data) {
|
||||||
return codec.decodeLinkResult(await exec('link.telegram.merge', codec.encodeLinkTelegram(data)));
|
return codec.decodeLinkResult(await exec('link.telegram.merge', codec.encodeLinkTelegram(data)));
|
||||||
},
|
},
|
||||||
|
async linkVK(code, deviceId, codeVerifier) {
|
||||||
|
return codec.decodeLinkResult(await exec('link.vk.confirm', codec.encodeLinkVK(code, deviceId, codeVerifier)));
|
||||||
|
},
|
||||||
|
async linkVKMerge(code, deviceId, codeVerifier) {
|
||||||
|
return codec.decodeLinkResult(await exec('link.vk.merge', codec.encodeLinkVK(code, deviceId, codeVerifier)));
|
||||||
|
},
|
||||||
async linkUnlink(kind) {
|
async linkUnlink(kind) {
|
||||||
return codec.decodeLinkResult(await exec('link.unlink', codec.encodeLinkUnlink(kind)));
|
return codec.decodeLinkResult(await exec('link.unlink', codec.encodeLinkUnlink(kind)));
|
||||||
},
|
},
|
||||||
@@ -304,7 +327,11 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
if (pe) onEvent(pe);
|
if (pe) onEvent(pe);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!ctrl.signal.aborted) onError?.(toGatewayError(e));
|
if (!ctrl.signal.aborted) {
|
||||||
|
const maintMs = maintenanceRetryMs(e);
|
||||||
|
if (maintMs !== null) reportMaintenance(maintMs);
|
||||||
|
onError?.(toGatewayError(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => ctrl.abort();
|
return () => ctrl.abort();
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// VK ID web login for linking a VK identity to the current account from a browser.
|
||||||
|
// Unlike the VK Mini App (whose signed launch params authenticate offline), a browser
|
||||||
|
// has no launch signature, so it runs the VK ID raw OAuth 2.1 flow (PKCE, no @vkid/sdk):
|
||||||
|
// a full-page redirect to VK's hosted login, then a callback to our redirect URL carrying
|
||||||
|
// the authorization code. The gateway completes the confidential code exchange. This is
|
||||||
|
// web-only — a full-page redirect would strand a native Mini App webview, where the
|
||||||
|
// platform identity is already linked anyway.
|
||||||
|
|
||||||
|
import { insideTelegram } from './telegram';
|
||||||
|
import { insideVK } from './vk';
|
||||||
|
|
||||||
|
function isMock(): boolean {
|
||||||
|
return import.meta.env.MODE === 'mock';
|
||||||
|
}
|
||||||
|
|
||||||
|
function vkAppId(): string {
|
||||||
|
return ((import.meta.env.VITE_VK_APP_ID as string | undefined) ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function vkRedirectUrl(): string {
|
||||||
|
return ((import.meta.env.VITE_VK_ID_REDIRECT_URL as string | undefined) ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkWebLinkAvailable reports whether the "Link VK" control should be shown: on the plain
|
||||||
|
* web (not inside a Telegram/VK Mini App, where a redirect would break the webview) and
|
||||||
|
* either the mock build or a configured VK ID app id and redirect URL.
|
||||||
|
*/
|
||||||
|
export function vkWebLinkAvailable(): boolean {
|
||||||
|
if (insideTelegram() || insideVK()) return false;
|
||||||
|
if (isMock()) return true;
|
||||||
|
return vkAppId() !== '' && vkRedirectUrl() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'vkid.link';
|
||||||
|
|
||||||
|
// PendingState is stashed before the redirect and consumed on the callback: the PKCE
|
||||||
|
// verifier (needed for the gateway exchange), the CSRF state, and whether this is an
|
||||||
|
// initial link or the re-authorization for a merge (VK codes are single-use, so a merge
|
||||||
|
// cannot reuse the link's code — it re-authorizes for a fresh one).
|
||||||
|
type PendingState = { verifier: string; state: string; mode: 'link' | 'merge' };
|
||||||
|
|
||||||
|
/** VKLinkCallback carries a verified VK ID authorization callback for the gateway exchange. */
|
||||||
|
export type VKLinkCallback = { code: string; deviceId: string; verifier: string; mode: 'link' | 'merge' };
|
||||||
|
|
||||||
|
// b64url encodes bytes as base64url without padding (the PKCE / VK ID alphabet).
|
||||||
|
function b64url(bytes: Uint8Array): string {
|
||||||
|
let s = '';
|
||||||
|
for (const b of bytes) s += String.fromCharCode(b);
|
||||||
|
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomToken(bytes: number): string {
|
||||||
|
const a = new Uint8Array(bytes);
|
||||||
|
crypto.getRandomValues(a);
|
||||||
|
return b64url(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function challengeFrom(verifier: string): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
||||||
|
return b64url(new Uint8Array(digest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* startVKLink begins VK ID web authorization by redirecting the whole tab to VK's hosted
|
||||||
|
* login. The PKCE verifier and CSRF state survive the redirect in sessionStorage; VK
|
||||||
|
* returns to VITE_VK_ID_REDIRECT_URL with ?code&device_id&state, processed on the next
|
||||||
|
* boot via pendingVKLink. mode distinguishes an initial link from a merge re-auth. The
|
||||||
|
* mock build never leaves the SPA.
|
||||||
|
*/
|
||||||
|
export async function startVKLink(mode: 'link' | 'merge'): Promise<void> {
|
||||||
|
if (isMock()) return;
|
||||||
|
const verifier = randomToken(48); // 64 base64url chars (VK ID requires 43..128)
|
||||||
|
const state = randomToken(24); // 32 base64url chars (VK ID requires >= 32)
|
||||||
|
const challenge = await challengeFrom(verifier);
|
||||||
|
const pending: PendingState = { verifier, state, mode };
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(pending));
|
||||||
|
const url = new URL('https://id.vk.com/authorize');
|
||||||
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('client_id', vkAppId());
|
||||||
|
url.searchParams.set('redirect_uri', vkRedirectUrl());
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
url.searchParams.set('code_challenge', challenge);
|
||||||
|
url.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
url.searchParams.set('scope', 'vkid.personal_info');
|
||||||
|
location.assign(url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripCallbackQuery removes the OAuth query params from the URL so a refresh does not
|
||||||
|
// re-process the callback, keeping the path and any hash route intact.
|
||||||
|
function stripCallbackQuery(): void {
|
||||||
|
if (typeof history !== 'undefined' && history.replaceState) {
|
||||||
|
history.replaceState(null, '', location.pathname + location.hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pendingVKLink extracts and clears a VK ID authorization callback from the current URL,
|
||||||
|
* verifying the returned state against the one stored before the redirect (CSRF). It
|
||||||
|
* returns null on a normal load or when the state does not match, and always strips the
|
||||||
|
* query so a refresh does not re-run it.
|
||||||
|
*/
|
||||||
|
export function pendingVKLink(): VKLinkCallback | null {
|
||||||
|
if (typeof location === 'undefined') return null;
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
const code = q.get('code');
|
||||||
|
const deviceId = q.get('device_id');
|
||||||
|
const returnedState = q.get('state');
|
||||||
|
if (!code || !deviceId || !returnedState) return null;
|
||||||
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
stripCallbackQuery();
|
||||||
|
if (!raw) return null;
|
||||||
|
let pending: PendingState;
|
||||||
|
try {
|
||||||
|
pending = JSON.parse(raw) as PendingState;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (pending.state !== returnedState) return null;
|
||||||
|
return { code, deviceId, verifier: pending.verifier, mode: pending.mode };
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
|
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
|
||||||
|
import { startVKLink, vkWebLinkAvailable } from '../lib/vkid';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import {
|
import {
|
||||||
awayDurationOk,
|
awayDurationOk,
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
let emailSent = $state(false);
|
let emailSent = $state(false);
|
||||||
// A pending irreversible merge surfaced after the code/widget was verified; the
|
// A pending irreversible merge surfaced after the code/widget was verified; the
|
||||||
// dialog confirms it. tgData holds the Telegram widget payload for the merge step.
|
// dialog confirms it. tgData holds the Telegram widget payload for the merge step.
|
||||||
let pendingMerge = $state<null | { kind: 'email' | 'telegram'; name: string; games: number; friends: number }>(null);
|
let pendingMerge = $state<null | { kind: 'email' | 'telegram' | 'vk'; name: string; games: number; friends: number }>(null);
|
||||||
let tgData = '';
|
let tgData = '';
|
||||||
// The change-email sub-form is open (a new address is being entered/confirmed).
|
// The change-email sub-form is open (a new address is being entered/confirmed).
|
||||||
let changingEmail = $state(false);
|
let changingEmail = $state(false);
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
let deleteCode = $state('');
|
let deleteCode = $state('');
|
||||||
let deletePhrase = $state('');
|
let deletePhrase = $state('');
|
||||||
const telegramLinkable = loginWidgetAvailable();
|
const telegramLinkable = loginWidgetAvailable();
|
||||||
|
const vkLinkable = vkWebLinkAvailable();
|
||||||
|
|
||||||
function defaultTz(): string {
|
function defaultTz(): string {
|
||||||
const b = browserOffset();
|
const b = browserOffset();
|
||||||
@@ -82,7 +84,12 @@
|
|||||||
notificationsInAppOnly = p.notificationsInAppOnly;
|
notificationsInAppOnly = p.notificationsInAppOnly;
|
||||||
variantPrefs = [...p.variantPreferences];
|
variantPrefs = [...p.variantPreferences];
|
||||||
}
|
}
|
||||||
onMount(populate);
|
onMount(() => {
|
||||||
|
populate();
|
||||||
|
// A VK ID web-link callback captured on boot (app.vkLinkPending) is finished here, since
|
||||||
|
// the redirect back from VK lost the in-app route and lands the app on a fresh mount.
|
||||||
|
if (app.vkLinkPending) void processVKLink();
|
||||||
|
});
|
||||||
|
|
||||||
const awayStart = $derived(`${startH}:${startM}`);
|
const awayStart = $derived(`${startH}:${startM}`);
|
||||||
const awayEnd = $derived(`${endH}:${endM}`);
|
const awayEnd = $derived(`${endH}:${endM}`);
|
||||||
@@ -180,8 +187,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addVK starts VK ID web login for linking: it redirects the whole tab to VK's hosted login,
|
||||||
|
// returning to the app with an auth code that boot hands back to processVKLink.
|
||||||
|
function addVK() {
|
||||||
|
void startVKLink('link');
|
||||||
|
}
|
||||||
|
|
||||||
|
// processVKLink finishes a VK ID web-link callback captured on boot: it exchanges the code via
|
||||||
|
// the gateway and either completes the link or opens the merge dialog. VK's authorization code
|
||||||
|
// is single-use, so a required merge re-authorizes for a fresh code (see confirmMerge); that
|
||||||
|
// returning 'merge' callback lands back here and completes.
|
||||||
|
async function processVKLink() {
|
||||||
|
const cb = app.vkLinkPending;
|
||||||
|
app.vkLinkPending = null;
|
||||||
|
if (!cb) return;
|
||||||
|
try {
|
||||||
|
if (cb.mode === 'merge') {
|
||||||
|
await applyLinkResult(await gateway.linkVKMerge(cb.code, cb.deviceId, cb.verifier));
|
||||||
|
populate();
|
||||||
|
showToast(t('profile.merged'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await gateway.linkVK(cb.code, cb.deviceId, cb.verifier);
|
||||||
|
if (r.status === 'merge_required') {
|
||||||
|
pendingMerge = { kind: 'vk', name: r.secondaryDisplayName, games: r.secondaryGames, friends: r.secondaryFriends };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await applyLinkResult(r);
|
||||||
|
populate();
|
||||||
|
showToast(t('profile.linked'));
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmMerge() {
|
async function confirmMerge() {
|
||||||
if (!pendingMerge) return;
|
if (!pendingMerge) return;
|
||||||
|
// A VK merge cannot reuse VK's single-use authorization code, so it re-authorizes for a fresh
|
||||||
|
// one; the returning callback completes it (processVKLink). Email/Telegram re-send their proof.
|
||||||
|
if (pendingMerge.kind === 'vk') {
|
||||||
|
void startVKLink('merge');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const r =
|
const r =
|
||||||
pendingMerge.kind === 'email'
|
pendingMerge.kind === 'email'
|
||||||
@@ -344,7 +391,7 @@
|
|||||||
|
|
||||||
<!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email
|
<!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email
|
||||||
(add) triggers the merge dialog below. On the web an account can add Telegram via the
|
(add) triggers the merge dialog below. On the web an account can add Telegram via the
|
||||||
login widget; adding VK on the web is deferred (no VK OAuth) and inside a Mini App the
|
login widget or VK via VK ID web login (a full-page redirect); inside a Mini App the
|
||||||
host provider is already linked. Email is never unlinked — it is changed. Unlink is
|
host provider is already linked. Email is never unlinked — it is changed. Unlink is
|
||||||
offered only when another identity remains (the backend also refuses the last one). -->
|
offered only when another identity remains (the backend also refuses the last one). -->
|
||||||
<section class="accounts">
|
<section class="accounts">
|
||||||
@@ -413,6 +460,10 @@
|
|||||||
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if vkLinkable}
|
||||||
|
<button class="ghost tg" onclick={addVK} disabled={!connection.online}>
|
||||||
|
<img src="vk-logo.svg" alt="" width="18" height="18" />{t('profile.linkVK')}
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+47
-3
@@ -1,7 +1,42 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its
|
||||||
|
* __BOOT_VERSION__ placeholder with the same VITE_APP_VERSION build-arg that feeds __APP_VERSION__.
|
||||||
|
* The guard runs before the bundle, so it cannot read the bundle's version; this lets its on-demand
|
||||||
|
* diagnostic report name the client version anyway. Falls back to "dev" for a local build.
|
||||||
|
*/
|
||||||
|
function injectBootVersion(): Plugin {
|
||||||
|
const version = process.env.VITE_APP_VERSION || 'dev';
|
||||||
|
return {
|
||||||
|
name: 'inject-boot-version',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replace(/__BOOT_VERSION__/g, version);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* emitPolyfills writes the prebuilt core-js bundle to `dist/polyfills.js`. The index.html gate
|
||||||
|
* script loads it via document.write only on an old engine that lacks the es2020+ runtime APIs the
|
||||||
|
* app uses (globalThis, structuredClone, Array.at, …) — e.g. the Chromium 66 Android System WebView
|
||||||
|
* behind the Telegram/VK in-app browser — before the deferred module runs. A modern engine never
|
||||||
|
* requests it, so it adds nothing to that payload and stays out of the module graph the bundle-size
|
||||||
|
* gate measures.
|
||||||
|
*/
|
||||||
|
function emitPolyfills(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'emit-polyfills',
|
||||||
|
generateBundle() {
|
||||||
|
const src = readFileSync(resolve(import.meta.dirname, 'node_modules/core-js-bundle/minified.js'), 'utf8');
|
||||||
|
this.emitFile({ type: 'asset', fileName: 'polyfills.js', source: src });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over
|
// The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over
|
||||||
// h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can
|
// h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can
|
||||||
// not speak h2c directly) talks to the dev server on the same origin. In `mock`
|
// not speak h2c directly) talks to the dev server on the same origin. In `mock`
|
||||||
@@ -19,7 +54,9 @@ export default defineConfig(({ mode }) => ({
|
|||||||
// so a missing build-arg never breaks the build.
|
// so a missing build-arg never breaks the build.
|
||||||
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'),
|
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'),
|
||||||
},
|
},
|
||||||
plugins: [svelte()],
|
// emitPolyfills ships dist/polyfills.js (loaded only by old engines; see its docstring + the
|
||||||
|
// index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic.
|
||||||
|
plugins: [svelte(), emitPolyfills(), injectBootVersion()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy:
|
proxy:
|
||||||
@@ -33,7 +70,14 @@ export default defineConfig(({ mode }) => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
target: 'es2022',
|
// Down-level to es2019 so an old Android System WebView (seen: Telegram/VK in-app WebView on
|
||||||
|
// Chromium 66, Android 9) can parse the bundle — esbuild lowers es2020+ syntax (?., ??, private
|
||||||
|
// fields, static blocks, …) that Chrome 66 rejects with "Unexpected token ?", which left the SPA
|
||||||
|
// on a white screen. esbuild lowers syntax only, not runtime APIs — the es2020+ globals the
|
||||||
|
// bundle/deps call at runtime (globalThis, structuredClone, Array.at, …) are covered separately
|
||||||
|
// by the conditional core-js loader (emitPolyfills + the index.html gate), loaded only on an
|
||||||
|
// engine that actually lacks them.
|
||||||
|
target: 'es2019',
|
||||||
// Emit sourcemaps everywhere except the production build. A shipped `.map`
|
// Emit sourcemaps everywhere except the production build. A shipped `.map`
|
||||||
// carries full `sourcesContent` — the entire TypeScript/Svelte source — and the
|
// carries full `sourcesContent` — the entire TypeScript/Svelte source — and the
|
||||||
// gateway/landing images serve `dist/` verbatim, so production maps would expose
|
// gateway/landing images serve `dist/` verbatim, so production maps would expose
|
||||||
|
|||||||
Reference in New Issue
Block a user