Compare commits

...

47 Commits

Author SHA1 Message Date
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer fcedadcb5b Merge pull request 'feat(gateway): unsupported-engine telemetry beacon + Grafana counter' (#177) from feature/unsupported-engine-telemetry into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / deploy (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
2026-07-04 21:16:23 +00:00
Ilia Denisov 2feb638329 feat(gateway): unsupported-engine telemetry beacon + Grafana counter
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
The index.html boot guard now fires a fire-and-forget beacon (POST /telemetry/unsupported)
when it turns a client away on the unsupported-engine screen, so the owner can see — on the
Users dashboard beside "app opens" — how many real clients hit it and on which engines.

- Client: navigator.sendBeacon (fetch fallback) with a localStorage dedup keyed by app version
  + reason + Chromium, so a user reopening the app is one report, not ten.
- Gateway: a new unauthenticated POST /telemetry/unsupported handler (the client never booted,
  so it carries no session), per-IP public-limited and body-capped, mirroring the export-download
  route. It folds the beacon into the OTel counter unsupported_engine_total {reason =
  no_bigint/no_proxy/boot_error/other, chromium}, with reason allow-listed and the Chromium major
  reduced to a bounded range (normalizeUnsupported) so a spoofed beacon cannot inflate the metric
  cardinality; the full user agent is logged, not labelled.
- Caddy: /telemetry/* added to the @gateway matcher (else it falls to the landing catch-all).
- Grafana: two panels on the Scrabble — Users dashboard (by reason, by Chromium major).
- Docs: ARCHITECTURE.md §11.

Tests: recordUnsupportedEngine (metric split), normalizeUnsupported (cardinality bounding), and
the handler route end to end (204 / 405 / counter). go build+vet+test and gofmt clean; ui
check/build/e2e green; the client beacon + dedup verified against a forced hard-gate (BigInt
removed) — one POST, deduped on reopen, correct reason/chromium/version labels.
2026-07-04 23:03:47 +02:00
developer 4dfedd02a3 feat(ui): support old Android in-app WebViews (es2019 + core-js + engine screen)
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m5s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m54s
Old Android System WebViews (the Telegram/VK in-app browser; a real device with Google
Play auto-updates the WebView, but emulators / no-Play / restricted devices stay frozen)
showed a white screen. Two causes, fixed in order:

- build.target es2022 shipped ?./?? verbatim, which the old engine cannot parse. Lowered
  to es2019 so esbuild down-levels the syntax.
- The es2020+ runtime globals the bundle and its deps call (globalThis, structuredClone,
  Array.at, ...) are missing on those engines. A conditional core-js loader (emitPolyfills
  writes dist/polyfills.js, document.write'd by an index.html gate only when needed) covers
  them; modern engines download nothing, so the bundle-size budget is untouched.

BigInt (the 64-bit FlatBuffers timestamp decode) and Proxy (Svelte 5 runes) cannot be
polyfilled, so the effective floor is Chrome 67. Below it, a permanent ES5 boot guard in
index.html shows a friendly "this device's OS or browser can't run the app" screen (with
the web-version link inside a Mini App) and a "Diagnostic information" view with a Copy
button, instead of a white screen. A reactive net raises the same screen on an uncaught
boot error that never signals window.__booted.

Board tile glyphs used container-query units (cqw, Chrome 105+) under .cell font-size:0,
so they collapsed to 0px on Chrome 74 (invisible letters); added vmin fallbacks.

The unsupported-engine telemetry beacon is a separate follow-up PR.
2026-07-04 20:36:05 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 44117e906c Merge pull request 'fix(ci): prod build needs EXPORT_SIGN_KEY for compose interpolation' (#174) from fix/prod-build-export-sign-key into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 18s
CI / ui (push) Successful in 1m5s
CI / conformance (push) Successful in 9s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-03 21:42:05 +00:00
Ilia Denisov c90331b189 fix(ci): prod build needs EXPORT_SIGN_KEY for compose interpolation
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
The first v1.8.0 prod-deploy build job failed: `docker compose build` interpolates the
WHOLE compose file, and the backend's :?-guarded EXPORT_SIGN_KEY — added with the
finished-game export after the last prod release (v1.7.0), so the prod build never
exercised it — was absent from the build job's env, tripping the guard before any image
built. Prod was untouched (build-only failure: no image pushed, no deploy, no migration).

Add EXPORT_SIGN_KEY (audited every :?-guarded compose var against the build job env — it
was the only genuinely-missing one; TELEGRAM_MINIAPP_URL is derived in the run step).
Verified by reproducing the build-job env + `docker compose config`.
2026-07-03 23:37:48 +02:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 0dfecc2af7 Merge pull request 'fix(ci): arm the maintenance flag on the test-contour deploy' (#172) from fix/maintenance-flag-test-contour into development
CI / deploy (push) Successful in 1m44s
CI / changes (pull_request) Successful in 1s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 8s
2026-07-03 21:10:14 +00:00
Ilia Denisov 446ea2ac45 fix(ci): arm the maintenance flag on the test-contour deploy
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The maintenance overlay never showed on the test contour: only prod-deploy.sh raised
the caddy maintenance flag, so a test redeploy was a bare gateway/backend recreate — the
SPA saw a transient 502 ("Reconnecting…"), never the 503 + X-Scrabble-Maintenance marker
the overlay keys on. So the feature (PR #171) was unverifiable off prod.

Raise the flag around the recreate in ci.yaml's deploy job too, mirroring prod. Ordering
matters because of the config reseed: ci does `rm -rf $conf` + recreate, so the running
caddy sits on the stale pre-reseed bind mount and cannot see a flag written to the new
dir until it is recreated. So: raise the flag, force-recreate caddy FIRST (onto the fresh
mount, where it carries the flag and 503s), then recreate gateway/backend (the window),
then the other config services, then lower it. A trap clears the flag if the step fails,
and the next deploy's reseed wipes a stale one — so the contour can't stick in maintenance.
The caddy-routed probes run in the next step, after the flag is lowered.

Prod was already correct (prod-deploy.sh); this only makes the test contour faithful so the
overlay + reload can be seen there. An open SPA must already be on the PR-#171 bundle (which
carries the overlay code) — reload once to bootstrap onto it.
2026-07-03 23:04:19 +02:00
developer 01a9249002 Merge pull request 'feat(ui): in-session maintenance overlay on the edge 503 marker' (#171) from feature/maintenance-overlay into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m5s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m6s
2026-07-03 20:52:05 +00:00
Ilia Denisov 7dcd62fdd7 feat(ui): reload the SPA on maintenance recovery
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
The deploy that ends a maintenance window may ship a client-incompatible change
(wire/schema bump), and the in-session bundle is the old one. On recovery, reload to
pick up the fresh client instead of just hiding the overlay. Ordering is safe: the edge
maintenance flag (deploy/prod-deploy.sh) spans the WHOLE roll and clears only at script
exit — after the gateway (which serves the embedded SPA) has rolled — so the edge 503s
everything until the entire deploy is live; recovery therefore always serves the new SPA.
A window.__maint.recover() hook + e2e cover the reload.
2026-07-03 22:46:55 +02:00
Ilia Denisov d67e582c03 feat(ui): in-session maintenance overlay on the edge 503 marker
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The edge caddy 503 carries X-Scrabble-Maintenance during a deploy window, but a user
already in the running SPA only sees their calls start failing — the static caddy page
catches only a fresh load. Detect that marker (strictly — not any transient
'unavailable', which the Connecting indicator already covers) on the raw ConnectError at
the two transport catch sites (unary exec + the live subscribe stream), before
toGatewayError discards the response headers, and raise a non-dismissable dimmed overlay
that mirrors the static caddy page. It self-clears: a capped-backoff poll (a cheap read,
mirroring connection.svelte.ts) lifts it on the first success, and a manual "retry"
button forces an immediate re-check — so it can never get stuck. On detection the read
retry loop fails fast, so a window doesn't burn the retry budget on every call.

- pure detector maintenance.ts (maintenanceRetryMs / parseRetryAfterMs) + unit tests;
  the store + self-clearing poll in maintenance.svelte.ts (mirrors connection.svelte.ts)
- MaintenanceOverlay.svelte (clones Splash's fixed/inset/dimmed shell, non-dismissable),
  mounted app-global in App.svelte after Coachmark
- transport.ts detects + reports at both catch sites, clears on any successful read
- i18n RU "Технические работы" / EN "Under maintenance"; a window.__maint mock hook
  (the mock can't emit a real 503) + a Playwright spec

Prod is same-origin (VITE_GATEWAY_URL empty) so the marker header is readable without a
CORS expose-header. Verified: pnpm check (0), unit (402), build, e2e (186, Chromium+WebKit).
2026-07-03 22:40:49 +02:00
developer 75fe07865a Merge pull request 'feat(deploy): prod OOM swap cushion + edge maintenance page' (#170) from feature/prod-hardening into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m43s
2026-07-03 20:28:29 +00:00
Ilia Denisov 597e200f37 Merge remote-tracking branch 'origin/development' into feature/prod-hardening
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m40s
2026-07-03 22:17:05 +02:00
developer 3ef18d33b6 Merge pull request 'feat(gateway): enable GATEWAY_HONEYTOKEN + GF_SMTP_ENABLED on both contours' (#169) from feature/enable-honeytoken into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 1m6s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m35s
2026-07-03 20:16:44 +00:00
Ilia Denisov c8601c0115 feat(deploy): prod OOM swap cushion + edge maintenance page
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
Two prod-deploy hardening measures (owner-requested):

- Swap file (Ansible common role, swap_size=1G, vm.swappiness=10). The per-container
  memory caps enforce that one service can't eat all RAM, but they overcommit the
  1.9 GiB main host (~2.8 GiB of caps), so a simultaneous spike could hit the kernel
  OOM-killer (and it might pick postgres). A small swap absorbs the overshoot.
  Idempotent, builtin-only (no ansible.posix).

- Edge maintenance page. prod-deploy.sh raises a flag around the rolling swap /
  migration window that the caddy edge serves a static 503 "технические работы" page
  from (deploy/caddy/maintenance.html), for the user-facing routes only — /_gm
  (Grafana) stays reachable. Cleared on any exit (success, health failure + rollback,
  or error) by a shell trap so it can never stick on. The 503 carries Retry-After +
  an X-Scrabble-Maintenance marker so a follow-up SPA overlay can tell a planned
  window apart from a transient error (the static page only catches a fresh load; an
  in-session user needs the app-side overlay). Not zero-downtime — the single
  stateful backend still blips — but the window is graceful instead of raw 502s.

Verified: caddy validate; the gate 503s every non-/_gm path incl. the Connect/gRPC
edge and serves the page + markers; /_gm bypasses; toggling needs no reload
(per-request stat). Ansible --syntax-check + compose config (base+prod) pass.
2026-07-03 22:09:37 +02:00
Ilia Denisov a0021d1994 feat(gateway): wire GATEWAY_HONEYTOKEN through the deploy
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
The planted honeytoken bearer trap already existed in the gateway + compose, but
no workflow fed GATEWAY_HONEYTOKEN, so it was always empty (inert). Wire the
per-contour TEST_/PROD_GATEWAY_HONEYTOKEN secret into the ci deploy, prod-deploy
and prod-rollback env (the prod path via the shared deploy/write-prod-env.sh),
document it (deploy/README + .env.example), and fix the stale compose comment.

Empty secret = trap off (no ":?" guard), so a deploy is safe before the operator
sets the value + plants the bait. On prod (IP ban on) presenting it earns a 24h
ban + alarm; on test (ban off) it logs + a ban metric.

GF_SMTP_ENABLED is enabled separately via the TEST_/PROD_GF_SMTP_ENABLED Gitea
variables (=true) — no code change.
2026-07-03 21:36:23 +02:00
developer 4b4dcab9b6 Merge pull request 'chore(deploy): dedupe & regroup Gitea CI variables/secrets' (#168) from feature/ci-vars-dedupe into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m4s
CI / conformance (push) Successful in 8s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s
2026-07-03 19:16:33 +00:00
Ilia Denisov 7f85362288 chore(deploy): dedupe & regroup Gitea CI variables/secrets
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
Collapse identical TEST_/PROD_ pairs to single unprefixed Gitea entries, derive
the public URLs from PUBLIC_BASE_URL at deploy time, and share the prod env.sh
renderer between deploy and rollback.

- Collapse to one unprefixed variable: DICT_VERSION, SMTP_RELAY_HOST/PORT/TLS,
  GRAFANA_SMTP_PORT, VITE_VK_APP_LINK, VITE_VK_APP_ID (one Selectel relay + one
  pair of VK apps serve every contour). Secrets collapsed by the owner:
  SMTP_RELAY_USER/PASS, GATEWAY_VK_APP_SECRET, GATEWAY_VK_ID_CLIENT_SECRET.
- Derive at deploy from PUBLIC_BASE_URL (no longer stored): TELEGRAM_MINIAPP_URL,
  GRAFANA_ROOT_URL, VITE_VK_ID_REDIRECT_URL. Removes the prod/test asymmetry and
  fills the missing test VITE_VK_APP_ID (VK web login was half-configured on test).
- Extract deploy/write-prod-env.sh + write-prod-bot-env.sh, shared by prod-deploy
  and prod-rollback so the two cannot drift: a rollback now re-renders the FULL
  runtime env (email / VK login / Grafana alerts previously went dark after a
  rollback) and passes TELEGRAM_SUPPORT_CHAT_ID.
- Single-source DICT_VERSION (CI env + both deploys), fix the v1.3.0/v1.3.1 drift in
  .env.example/README, correct the misleading honeytoken/abuse-ban compose comment,
  and rewrite the deploy/README variable list (+ the previously undocumented
  GATEWAY_VK_APP_SECRET and TELEGRAM_SUPPORT_CHAT_ID).

The Gitea variables are reworked via the API; the stale TEST_/PROD_ entries are
deleted after the test contour goes green.
2026-07-03 21:07:07 +02:00
developer 72e9b600b0 Merge pull request 'fix(account): dedupe colliding identities on merge, journaling to the dossier' (#167) from feature/merge-email-dedupe into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
2026-07-03 17:48:49 +00:00
Ilia Denisov 0eefbfd6a4 fix(account): dedupe colliding identities on merge, journaling to the dossier
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
An account merge blanket-reassigned all of the secondary's identities to the primary,
so merging two accounts that each had a confirmed email (or telegram/vk) left the
survivor with two identities of one kind — which the profile and the retention dossier
both treat as singular (the profile showed an arbitrary one; a later change-email
journaled only one). The merge now keeps the primary's identity and journals the
secondary's colliding one to retained_identities (reason=merge) before dropping it, so
the survivor has one identity per kind and the absorbed credential still lands in the
legal dossier.

- migration 00008: widen retained_identities.reason CHECK to admit 'merge'
  (expand-contract — Up only widens the set, so an image rollback stays DB-safe).
- accountmerge: dedupeIdentities + retainMergedIdentity before the identity reassign.
- inttest TestAccountMergeDedupesEmail; docs ARCHITECTURE §4 + retention.go reason note.
2026-07-03 19:32:52 +02:00
developer c680e695d3 Merge pull request 'feat(account): VK ID web login to link a VK identity from a browser' (#166) from feature/vk-web-link into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m4s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m5s
2026-07-03 16:09:57 +00:00
Ilia Denisov 2c465c01d2 feat(account): VK ID web login to link a VK identity from a browser
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
A browser has no signed VK Mini App launch params, so linking VK on the web uses
VK ID's raw OAuth 2.1 flow (PKCE, no @vkid/sdk): the SPA redirects to VK's hosted
login and returns with an authorization code, which the gateway exchanges
server-side (confidential, under the VK "Web" app's protected key) for the trusted
vk user id — then the existing link/merge machinery attaches or merges it.

- fbs LinkVKRequest{code, device_id, code_verifier}; codec + TS bindings.
- backend link.Service ConfirmVK/MergeVK/attachVK (KindVK, mirror Telegram),
  handleLinkVK[Merge], routes /user/link/vk[/merge], backendclient LinkVK[Merge].
- gateway internal/vkid confidential code exchange (id.vk.com/oauth2/auth);
  transcode link.vk.confirm/merge (registered only when configured) + config
  GATEWAY_VK_ID_{APP_ID,CLIENT_SECRET,REDIRECT_URL} + main wiring.
- UI lib/vkid (PKCE authorize redirect + callback), Profile "Link VK" control,
  boot callback handling; a merge re-authorizes for a fresh code (VK codes are
  single-use). Web-only (a redirect strands a Mini App webview).
- Deploy: VITE_VK_APP_ID + VITE_VK_ID_REDIRECT_URL build args + gateway env,
  ci.yaml/prod-deploy TEST_/PROD_ vars, compose/Dockerfile/.env.example/README.
- Tests: vkid exchange unit (string/number user_id, id_token fallback, errors),
  transcode link.vk, backend ConfirmVK/MergeVK inttest, codec encodeLinkVK.
- Docs: ARCHITECTURE §4, FUNCTIONAL(+ru), gateway README.
2026-07-03 17:59:33 +02:00
developer 60faa4f064 Merge pull request 'feat(observability): Grafana infra alerts + admin email notifications (PR4)' (#165) from feature/email-relay-pr4 into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m4s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
2026-07-03 14:05:45 +00:00
Ilia Denisov 854c4b3005 fix(deploy): stage blackbox config + derive bare Grafana SMTP from-address
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m28s
Two contour-deploy failures from PR4: (1) the deploy did not stage deploy/blackbox/, so
blackbox_exporter crash-looped on a missing config mount — add blackbox to the ci.yaml cp
and the prod-deploy tar; (2) Grafana rejects the 'Name <addr>' From form the backend go-mail
accepts and validates it even when SMTP is disabled, crash-looping Grafana — the deploy now
splits SMTP_RELAY_SERVICE_FROM into a bare GF_SMTP_FROM_ADDRESS + GRAFANA_SMTP_FROM_NAME
(handles both display and bare forms). Verified the sed split + compose render.
2026-07-03 15:30:39 +02:00
Ilia Denisov ec1bdfca00 fix(deploy): Grafana SMTP reuses relay host + explicit GRAFANA_SMTP_PORT
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 1s
Drop the separate GRAFANA_SMTP_HOST (it duplicated SMTP_RELAY_HOST): Grafana differs from
the backend only in needing the STARTTLS port, so GF_SMTP_HOST is composed from
SMTP_RELAY_HOST + GRAFANA_SMTP_PORT (an explicit per-contour var, no magic default), reusing
SMTP_RELAY_USER/PASS. Wired through ci/prod/.env.example/README.
2026-07-03 15:17:43 +02:00
Ilia Denisov 70f0f9e36a docs(architecture): observability alerting + admin-alert worker
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 1m8s
§11 gains the alerting layer: Grafana infra rules (scrape-down, edge error-rate/p99, host
mem/disk/cpu, postgres connections, TLS cert < 20d via blackbox), noDataState=OK, and the
backend admin-alert worker (coalesced email on new feedback / complaints).
2026-07-03 15:08:57 +02:00
Ilia Denisov 55f6176538 feat(deploy): Grafana infra alerts + blackbox cert probe + admin-alert wiring
Grafana: GF_SMTP from the shared relay (STARTTLS host:port) + alerting provisioning
(contact point → SERVICE_EMAIL, route-all policy, and rules for scrape-target down,
gateway internal-error rate + p99 latency, host mem/disk/cpu, postgres connections, and
TLS cert < 20 days). All rules noDataState=OK so an absent metric never false-alerts.
blackbox_exporter probes the edge caddy's TLS (probe_ssl_earliest_cert_expiry) — effective
on prod (caddy terminates TLS; contour caddy is HTTP-only so the metric is absent).
Wires the new env through compose (backend admin From/To, Grafana SMTP), ci.yaml (TEST_),
prod-deploy (PROD_ + env.sh), .env.example and the README var table.
2026-07-03 15:08:09 +02:00
Ilia Denisov 8d2cd97e17 feat(adminalert): operator email on new feedback / word complaints
New adminalert worker polls for feedback + word complaints arriving since the last check
and coalesces a burst into one digest email per interval (5 min), inert unless a distinct
admin sender (BACKEND_SMTP_ADMIN_FROM) and recipient (BACKEND_ADMIN_EMAIL, comma-separated
allowed) are configured. The mailer gains a per-message From override and splits a
comma-separated To into separate recipients (go-mail needs them as a list). Feedback/game
stores gain CountSince/CountComplaintsSince. Unit tests cover the digest, the skip-when-
empty, and the recipient split.
2026-07-03 14:53:51 +02:00
developer f1a12c2f44 Merge pull request 'feat(account): deletion = legal retention, not erasure (PR3)' (#164) from feature/email-relay-pr3 into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m4s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s
2026-07-03 12:19:13 +00:00
Ilia Denisov 4cc37e5760 fix(admin): unified user search across live + deleted, incl. the retention journal
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
The email/name/external-id search was scoped to one tab and only looked in the live
identities table, so a deleted account (whose credentials moved to retained_identities on
deletion) could not be found by the email/id it held. Now a people search spans live and
deleted accounts in one query (robots only on the Robots tab) and also matches the
retention journal and the retained real name; results carry a 'deleted' badge. Integration
test: a deleted account is found by its held email and external id.
2026-07-03 14:15:02 +02:00
Ilia Denisov 3faca690dd fix(delete): review follow-ups — admin Deleted filter, guest gate, dialog spacing
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
- Admin /users gains a Deleted scope (between People and Robots); every other scope now
  hides tombstoned accounts (deleted_at filter in UserFilter).
- Admin delete-user is offered for any non-deleted account (drop the not-guest gate, so a
  stale is_guest account can still be tombstoned).
- Harden EmailService.ConfirmCode to ClearGuest — defence-in-depth so no confirmed-email
  path leaves is_guest set (the currently-live paths already do).
- Space the delete/change dialog's action row from its input field.
Integration tests: the Deleted filter scoping + ConfirmCode guest promotion.
2026-07-03 13:59:43 +02:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
87 changed files with 3485 additions and 212 deletions
+82 -23
View File
@@ -26,12 +26,12 @@ on:
push:
branches: [development]
# The dictionary release the test suite validates against — the current
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
# unit/integration jobs inherit it. The deploy job overrides it per contour with
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
# The dictionary release. One Gitea variable is the single source of truth: the
# test suite validates against it here (inherited by the unit/integration jobs) and
# both contours' deploy jobs seed a fresh volume with the same value. A release bump
# is one edit (the variable). See deploy/README.md.
env:
DICT_VERSION: v1.3.1
DICT_VERSION: ${{ vars.DICT_VERSION }}
jobs:
# changes detects which areas a PR/push touched, so the test jobs can skip when
@@ -329,24 +329,42 @@ jobs:
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
# One VK Mini App serves every contour -> unprefixed secret.
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# VK ID web login (browser VK-identity linking): the VK ID "Web" app's protected key
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
# Signs the finished-game export download URLs (backend + compose interpolation).
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay. Empty host leaves the
# backend on the log mailer (email disabled) but the contour still boots.
SMTP_RELAY_USER: ${{ secrets.TEST_SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.TEST_SMTP_RELAY_PASS }}
SMTP_RELAY_HOST: ${{ vars.TEST_SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.TEST_SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.TEST_SMTP_RELAY_TLS }}
# Transactional email via the shared Selectel relay: one account for every
# contour -> unprefixed host/port/tls/user/pass. Empty host leaves the backend
# on the log mailer (email disabled) but the contour still boots.
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
# host:port. Empty leaves the alert worker off and Grafana SMTP disabled.
SMTP_RELAY_ADMIN_FROM: ${{ vars.TEST_SMTP_RELAY_ADMIN_FROM }}
ADMIN_EMAIL: ${{ vars.TEST_ADMIN_EMAIL }}
SMTP_RELAY_SERVICE_FROM: ${{ vars.TEST_SMTP_RELAY_SERVICE_FROM }}
SERVICE_EMAIL: ${{ vars.TEST_SERVICE_EMAIL }}
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
GF_SMTP_ENABLED: ${{ vars.TEST_GF_SMTP_ENABLED }}
# Canonical public origin for links in the email (this contour's URL);
# required by the backend whenever SMTP_RELAY_HOST is set.
PUBLIC_BASE_URL: ${{ vars.TEST_PUBLIC_BASE_URL }}
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
# TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL and VITE_VK_ID_REDIRECT_URL are derived
# from PUBLIC_BASE_URL in the run step below, not stored as their own variables.
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
@@ -359,12 +377,16 @@ jobs:
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
VITE_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
# Unset vars render empty -> the compose ":-" defaults apply.
# VK Mini App landing link + VK ID "Web" app id: one value each serves every
# contour -> unprefixed. VITE_VK_APP_ID also feeds the gateway (GATEWAY_VK_ID_APP_ID);
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
# VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the
# compose ":-" empty default. Other unset vars likewise fall to their defaults.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
DICT_VERSION: ${{ vars.TEST_DICT_VERSION }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
run: |
# Seed the config files to a stable host path. The runner checks out into
@@ -375,8 +397,34 @@ jobs:
conf="$HOME/.scrabble-deploy"
rm -rf "$conf"
mkdir -p "$conf"
cp -r caddy otelcol prometheus tempo grafana "$conf"/
cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
export SCRABBLE_CONFIG_DIR="$conf"
# Maintenance page for the redeploy window, mirroring prod-deploy.sh so the SPA
# overlay is exercised on the test contour too (not only prod). Raised just before
# the recreate and lowered once caddy is back (below); the trap clears it if the
# step fails so the contour never sticks in maintenance (and the reseed above wipes a
# stale flag anyway). The caddy-routed probes run in the NEXT step, after it is lowered.
maint_flag="$conf/caddy/on"
trap 'rm -f "$maint_flag"' EXIT
# Derive the public URLs from the one canonical origin instead of storing each as
# its own variable (paths are structural SPA routes / the Caddy /_gm sub-path).
# Exported before build so the VK ID redirect is baked into the SPA.
base="${PUBLIC_BASE_URL%/}"
export TELEGRAM_MINIAPP_URL="$base/telegram/"
export GRAFANA_ROOT_URL="$base/_gm/grafana/"
export VITE_VK_ID_REDIRECT_URL="$base/app/"
# Grafana's SMTP from_address must be a BARE address (it rejects the "Name" <addr>
# form the backend go-mail accepts) and validates it even when SMTP is disabled — a
# bad value crash-loops Grafana. Split the display-format SERVICE From into a bare
# address + name for Grafana; the backend keeps the full form.
svc_from="${SMTP_RELAY_SERVICE_FROM:-}"
case "$svc_from" in
*"<"*">"*)
export GRAFANA_SMTP_FROM_ADDRESS="$(printf '%s' "$svc_from" | sed -E 's/.*<([^>]+)>.*/\1/')"
export GRAFANA_SMTP_FROM_NAME="$(printf '%s' "$svc_from" | sed -E 's/[[:space:]]*<[^>]*>.*$//; s/^"//; s/"$//')" ;;
*)
export GRAFANA_SMTP_FROM_ADDRESS="$svc_from" ;;
esac
# Bot-link mTLS material for the test contour: a private CA + gateway/bot
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
@@ -389,12 +437,23 @@ jobs:
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
# main host omits both. Without the profile they would not start here.
docker compose --ansi never --profile telegram-local build --progress plain
# Raise the maintenance page, THEN bring caddy onto the reseeded config mount so it
# actually carries the flag: the running caddy sits on the stale pre-reseed mount (the
# dir was rm'd + recreated — see the force-recreate note below), so a flag written to
# the new dir is invisible until caddy is recreated. With the fresh caddy up, an open
# SPA sees the 503 marker + overlay for the whole recreate window, not a bare reconnect.
: > "$maint_flag"
docker compose --ansi never up -d --force-recreate --no-deps caddy
docker compose --ansi never --profile telegram-local up -d --remove-orphans
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
# pick up the fresh config.
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
# changed Grafana dashboard is ignored — force-recreate them to pick up the fresh
# config. (Caddy was already recreated above so it would carry the maintenance flag.)
docker compose --ansi never up -d --force-recreate --no-deps otelcol prometheus tempo grafana
# Lower the maintenance page: services are back. An open SPA's poll now gets through
# (once the gateway finishes booting) and reloads into the fresh client; the caddy
# probes in the next step see 200. The EXIT trap is a backstop if we failed earlier.
rm -f "$maint_flag"
- name: Probe the landing, gateway and backend
run: |
+53 -57
View File
@@ -40,12 +40,22 @@ jobs:
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
VITE_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }}
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
# VK Mini App link + VK ID "Web" app id: one value each serves every contour.
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
# VITE_GATEWAY_URL omitted: the SPA is served same-origin (compose ":-" default).
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
# `docker compose build` interpolates the WHOLE compose file, so every :?-guarded
# runtime var must be present at build even though it is not a build-arg — incl. the
# backend's EXPORT_SIGN_KEY (added with the finished-game export after v1.7.0, which is
# why the first v1.8.0 build tripped on it). POSTGRES_PASSWORD/GM_BASICAUTH_HASH above
# are here for the same reason; DICT_VERSION + the derived Mini App URL cover the rest.
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# PUBLIC_BASE_URL drives the derived VK ID redirect (baked into the SPA) and the
# Mini App URL; both are computed in the build step, not stored variables.
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
steps:
- uses: actions/checkout@v4
with:
@@ -59,6 +69,11 @@ jobs:
working-directory: deploy
run: |
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
# Derive the public URLs from the one canonical origin: the VK ID redirect is a
# build-arg baked into the SPA, and the Mini App URL satisfies the (profiled-out)
# bot service's compose ":?" guard during parse. See deploy/write-prod-env.sh.
base="${PUBLIC_BASE_URL%/}"
export VITE_VK_ID_REDIRECT_URL="$base/app/" TELEGRAM_MINIAPP_URL="$base/telegram/"
# The main-stack images via compose (reuses the build args, incl. VERSION);
# the bot separately, since it is profiled out of the prod compose.
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
@@ -83,28 +98,45 @@ jobs:
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay (confirm-codes).
SMTP_RELAY_USER: ${{ secrets.PROD_SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.PROD_SMTP_RELAY_PASS }}
SMTP_RELAY_HOST: ${{ vars.PROD_SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.PROD_SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.PROD_SMTP_RELAY_TLS }}
# Transactional email via the shared Selectel relay (confirm-codes): one account for
# every contour -> unprefixed host/port/tls/user/pass.
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails + Grafana infra alerts (distinct senders +
# recipients; Grafana uses the relay's STARTTLS host:port).
SMTP_RELAY_ADMIN_FROM: ${{ vars.PROD_SMTP_RELAY_ADMIN_FROM }}
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
# TELEGRAM_MINIAPP_URL and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in
# deploy/write-prod-env.sh, not stored variables.
steps:
- uses: actions/checkout@v4
with:
@@ -132,33 +164,8 @@ jobs:
run: |
umask 077
mkdir -p stage/certs-main
cat > stage/env.sh <<EOF
export REGISTRY='$REGISTRY'
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$TAG'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY'
export SMTP_RELAY_HOST='$SMTP_RELAY_HOST'
export SMTP_RELAY_PORT='$SMTP_RELAY_PORT'
export SMTP_RELAY_TLS='$SMTP_RELAY_TLS'
export SMTP_RELAY_USER='$SMTP_RELAY_USER'
export SMTP_RELAY_PASS='$SMTP_RELAY_PASS'
export SMTP_RELAY_FROM='$SMTP_RELAY_FROM'
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
# Shared with prod-rollback so the two paths render an identical runtime env.
APP_VERSION="$TAG" bash deploy/write-prod-env.sh stage/env.sh
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
@@ -169,7 +176,7 @@ jobs:
ssh_main 'mkdir -p /opt/scrabble/compose'
tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \
| ssh_main 'tar -C /opt/scrabble/compose -xzf -'
tar -C deploy -czf - caddy otelcol prometheus tempo grafana \
tar -C deploy -czf - caddy otelcol prometheus tempo grafana blackbox \
| ssh_main 'tar -C /opt/scrabble -xzf -'
tar -C stage -czf - certs-main \
| ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
@@ -197,7 +204,8 @@ jobs:
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
# PUBLIC_BASE_URL drives the derived Mini App URL in deploy/write-prod-bot-env.sh.
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
@@ -214,20 +222,8 @@ jobs:
run: |
umask 077
mkdir -p stage/certs-bot
cat > stage/env.bot.sh <<EOF
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TAG'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_SUPPORT_CHAT_ID='$TELEGRAM_SUPPORT_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
# Shared with prod-rollback so the two paths render an identical bot env.
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TAG" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
+30 -35
View File
@@ -51,13 +51,32 @@ jobs:
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
# Full runtime env — parity with prod-deploy's deploy-main so a rollback re-renders
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
SMTP_RELAY_ADMIN_FROM: ${{ vars.PROD_SMTP_RELAY_ADMIN_FROM }}
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
INPUT_TARGET: ${{ inputs.target_version }}
steps:
- uses: actions/checkout@v4
@@ -89,24 +108,9 @@ jobs:
run: |
umask 077
mkdir -p stage/certs-main
cat > stage/env.sh <<EOF
export REGISTRY='$REGISTRY'
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$TARGET'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
# Same writer as prod-deploy's deploy-main -> the rollback re-renders the FULL
# runtime env (not a subset), so email / VK login / Grafana alerts survive it.
APP_VERSION="$TARGET" bash deploy/write-prod-env.sh stage/env.sh
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
@@ -147,9 +151,11 @@ jobs:
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
# PUBLIC_BASE_URL drives the derived Mini App URL; SUPPORT_CHAT_ID for parity with deploy.
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
steps:
@@ -163,19 +169,8 @@ jobs:
run: |
umask 077
mkdir -p stage/certs-bot
cat > stage/env.bot.sh <<EOF
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TARGET'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
# Same writer as prod-deploy's deploy-bot (parity; includes TELEGRAM_SUPPORT_CHAT_ID).
BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TARGET" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
+18
View File
@@ -12,6 +12,7 @@ import (
"fmt"
"log"
"os/signal"
"strings"
"syscall"
"time"
@@ -20,6 +21,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/adminalert"
"scrabble/backend/internal/ads"
"scrabble/backend/internal/banview"
"scrabble/backend/internal/config"
@@ -44,6 +46,10 @@ import (
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
const telemetryShutdownTimeout = 5 * time.Second
// adminAlertInterval is how often the operator-alert worker checks for new feedback /
// complaints; a burst within one interval coalesces into a single digest email.
const adminAlertInterval = 5 * time.Minute
func main() {
cfg, err := config.Load()
if err != nil {
@@ -207,6 +213,18 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
feedbackSvc.SetNotifier(hub)
// Operator alert emails on new feedback / word complaints, coalesced into one digest
// per interval. Inert unless a distinct admin sender and recipient are configured.
if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" {
consoleURL := ""
if cfg.PublicBaseURL != "" {
consoleURL = strings.TrimRight(cfg.PublicBaseURL, "/") + "/_gm"
}
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, consoleURL, logger)
go alerts.Run(ctx, adminAlertInterval)
logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval))
}
// Robot opponent: provision its durable account pool (a hard startup
// dependency, like the dictionaries) and start its move driver. The matchmaker
// substitutes a pooled robot for a missing human after the wait window.
+5
View File
@@ -206,6 +206,11 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return Account{}, err
}
// Binding the first confirmed email promotes a guest to a durable account, matching the
// link and deeplink flows (defence-in-depth: no confirmed-email path leaves is_guest set).
if err := s.store.ClearGuest(ctx, accountID); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID)
}
+31 -4
View File
@@ -15,7 +15,11 @@ import (
// required plain-text body and doubles as the multipart/alternative fallback;
// HTML, when non-empty, is the preferred body a capable client renders instead.
type Message struct {
To string
// To is the recipient address, or several comma-separated (all get the one message).
To string
// From, when non-empty, overrides the configured sender for this message — the admin
// alert path uses a distinct From from the user-facing confirm-code sender.
From string
Subject string
Text string
HTML string
@@ -29,6 +33,18 @@ type Mailer interface {
Send(ctx context.Context, msg Message) error
}
// splitAddrs splits a comma-separated recipient list into trimmed, non-empty addresses.
func splitAddrs(list string) []string {
parts := strings.Split(list, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if a := strings.TrimSpace(p); a != "" {
out = append(out, a)
}
}
return out
}
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer
// instead, so a deployment without a relay still runs (the code lands in the log).
// TLS is always used and no client certificate is required — only the server
@@ -44,6 +60,11 @@ type SMTPConfig struct {
// port (implicit TLS on 465, STARTTLS otherwise); set it explicitly for a relay on
// a non-standard port (e.g. Selectel's 1127 = SSL, 1126 = STARTTLS).
TLS string
// AdminFrom / AdminTo drive the operator alert emails (new feedback / word complaints),
// distinct from the user-facing confirm-code sender. AdminTo may be several
// comma-separated addresses. Both empty disables the alert worker.
AdminFrom string
AdminTo string
}
const (
@@ -110,10 +131,16 @@ func (m SMTPMailer) Send(ctx context.Context, msg Message) error {
return fmt.Errorf("account: build mail client: %w", err)
}
out := mail.NewMsg()
if err := out.From(m.cfg.From); err != nil {
return fmt.Errorf("account: set From %q: %w", m.cfg.From, err)
from := m.cfg.From
if msg.From != "" {
from = msg.From
}
if err := out.To(msg.To); err != nil {
if err := out.From(from); err != nil {
return fmt.Errorf("account: set From %q: %w", from, err)
}
// To may carry several comma-separated recipients; go-mail wants them as separate
// arguments (a single joined string parses as one malformed address).
if err := out.To(splitAddrs(msg.To)...); err != nil {
return fmt.Errorf("account: set To %q: %w", msg.To, err)
}
out.Subject(msg.Subject)
+23 -1
View File
@@ -1,6 +1,28 @@
package account
import "testing"
import (
"slices"
"testing"
)
// TestSplitAddrs covers the comma-separated recipient parsing used for the admin alert
// To (several operator mailboxes in one message), including trimming and empty entries.
func TestSplitAddrs(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"a@x.ru", []string{"a@x.ru"}},
{"a@x.ru, b@y.ru", []string{"a@x.ru", "b@y.ru"}},
{" a@x.ru ,, b@y.ru ,", []string{"a@x.ru", "b@y.ru"}},
{"", nil},
}
for _, c := range cases {
if got := splitAddrs(c.in); !slices.Equal(got, c.want) {
t.Errorf("splitAddrs(%q) = %v, want %v", c.in, got, c.want)
}
}
}
// TestSMTPTLSMode covers the explicit TLS mode and the port-based fallback, including
// the non-standard Selectel ports (1127 = SSL, 1126 = STARTTLS) that the 465 heuristic
+5 -3
View File
@@ -21,9 +21,11 @@ import (
const RetentionTTL = 2 * 365 * 24 * time.Hour
// Reasons recorded on a retained_identities row: what detached the credential from its
// account. The row is written just before the live identities row is removed, preserving
// the legal dossier (which email/vk/tg was linked, and when) even as the identity frees
// for reuse. See docs/ARCHITECTURE.md §9.1.
// account (unlink / email change / account deletion here; an account merge that drops a
// same-kind colliding identity writes reason "merge" from the accountmerge package). The
// row is written just before the live identities row is removed, preserving the legal
// dossier (which email/vk/tg was linked, and when) even as the identity frees for reuse.
// See docs/ARCHITECTURE.md §9.1.
const (
retainUnlink = "unlink"
retainChange = "change"
+41 -15
View File
@@ -19,6 +19,9 @@ type UserListItem struct {
PreferredLanguage string
IsGuest bool
IsRobot bool
// IsDeleted marks a tombstoned account (deleted_at set), shown as a badge — a search
// spans both lists, so a result can be either live or deleted.
IsDeleted bool
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
// as a badge in the console list.
FlaggedHighRateAt time.Time
@@ -26,12 +29,14 @@ type UserListItem struct {
}
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
// one char) matched case-insensitively against the display name / any identity's external
// id; EmailExact is a strict (exact) match against an account's email identity. An empty
// value means no filter on that field.
// non-robot "people"); Deleted selects tombstoned accounts (every other scope hides them);
// NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = one char) matched
// case-insensitively against the display name / any identity's external id; EmailExact is a
// strict (exact) match against an account's email identity. An empty value means no filter
// on that field.
type UserFilter struct {
Robots bool
Deleted bool
NameMask string
ExternalIDMask string
EmailExact string
@@ -53,21 +58,42 @@ func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error)
return ok, nil
}
// userListWhere builds the shared WHERE clause and its positional args (from $1).
// userListWhere builds the shared WHERE clause and its positional args (from $1). On the
// Robots tab it lists/searches robots only. Otherwise a search (any of the name /
// external-id / email filters) spans live and deleted people alike — never robots — so the
// operator finds a match from one query regardless of the People / Deleted tab; the search
// also looks in the retention journal, so a deleted account is still found by the email /
// external id it held (those rows moved from identities to retained_identities on deletion)
// and by its retained real name. With no search, the People / Deleted tab scope applies.
func userListWhere(f UserFilter) (string, []any) {
args := []any{f.Robots}
where := robotExists + ` = $1`
if name := LikePattern(f.NameMask); name != "" {
name := LikePattern(f.NameMask)
ext := LikePattern(f.ExternalIDMask)
email := strings.ToLower(strings.TrimSpace(f.EmailExact))
searching := name != "" || ext != "" || email != ""
var args []any
var where string
switch {
case f.Robots:
where = robotExists + ` = true`
case searching:
where = robotExists + ` = false`
case f.Deleted:
where = robotExists + ` = false AND a.deleted_at IS NOT NULL`
default:
where = robotExists + ` = false AND a.deleted_at IS NULL`
}
if name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
where += fmt.Sprintf(` AND (a.display_name ILIKE $%d ESCAPE '\' OR a.deleted_display_name ILIKE $%d ESCAPE '\')`, len(args), len(args))
}
if ext := LikePattern(f.ExternalIDMask); ext != "" {
if ext != "" {
args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
where += fmt.Sprintf(` AND (EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\') OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.external_id ILIKE $%d ESCAPE '\'))`, len(args), len(args))
}
if email := strings.ToLower(strings.TrimSpace(f.EmailExact)); email != "" {
if email != "" {
args = append(args, email)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d)`, len(args))
where += fmt.Sprintf(` AND (EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d) OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.kind = 'email' AND r.external_id = $%d))`, len(args), len(args))
}
return where, args
}
@@ -75,7 +101,7 @@ func userListWhere(f UserFilter) (string, []any) {
// ListUsers returns the filtered admin user list, newest first, paginated.
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
where, args := userListWhere(f)
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot, (a.deleted_at IS NOT NULL) AS is_deleted
FROM backend.accounts a WHERE ` + where +
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
@@ -88,7 +114,7 @@ FROM backend.accounts a WHERE ` + where +
for rows.Next() {
var it UserListItem
var flagged sql.NullTime
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil {
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot, &it.IsDeleted); err != nil {
return nil, fmt.Errorf("account: scan user: %w", err)
}
if flagged.Valid {
+79
View File
@@ -27,6 +27,11 @@ import (
// without taking a dependency on the game package.
const statusActive = "active"
// retainReasonMerge is the retained_identities.reason for a credential dropped by a merge
// collision (both accounts held the same kind). It mirrors the account package's retain
// reasons, kept local to avoid importing that package's unexported constants.
const retainReasonMerge = "merge"
// Friendship statuses, highest precedence first, mirroring internal/social.
const (
friendAccepted = "accepted"
@@ -75,6 +80,9 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
return err
}
if err := dedupeIdentities(ctx, tx, primary, secondary); err != nil {
return err
}
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: identities: %w", err)
}
@@ -300,6 +308,77 @@ func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col pos
return err
}
// dedupeIdentities resolves a same-kind identity collision before the blanket identity
// reassign: when both accounts already hold an identity of the same kind (e.g. each has a
// confirmed email — reachable when two email-bearing accounts merge), the primary keeps
// its own and the secondary's is journaled to retained_identities (reason=merge) and
// removed. Without this the blanket reassign would leave the survivor with two identities
// of one kind (there is no per-account-kind unique on identities), which the profile and
// the retention dossier both treat as singular. Non-colliding identities are untouched and
// move with the blanket reassign.
func dedupeIdentities(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
var prows []model.Identities
if err := postgres.SELECT(table.Identities.Kind).
FROM(table.Identities).
WHERE(table.Identities.AccountID.EQ(postgres.UUID(primary))).
QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: primary identity kinds: %w", err)
}
occupied := make(map[string]struct{}, len(prows))
for _, r := range prows {
occupied[r.Kind] = struct{}{}
}
if len(occupied) == 0 {
return nil
}
var srows []model.Identities
if err := postgres.SELECT(
table.Identities.Kind, table.Identities.ExternalID,
table.Identities.Confirmed, table.Identities.CreatedAt,
).FROM(table.Identities).
WHERE(table.Identities.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: secondary identities: %w", err)
}
for _, s := range srows {
if _, dup := occupied[s.Kind]; !dup {
continue
}
if err := retainMergedIdentity(ctx, tx, secondary, s); err != nil {
return err
}
del := table.Identities.DELETE().WHERE(
table.Identities.AccountID.EQ(postgres.UUID(secondary)).
AND(table.Identities.Kind.EQ(postgres.String(s.Kind))).
AND(table.Identities.ExternalID.EQ(postgres.String(s.ExternalID))),
)
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: drop colliding %s identity: %w", s.Kind, err)
}
}
return nil
}
// retainMergedIdentity appends a retained_identities row for a secondary identity dropped
// by a merge collision (reason=merge), preserving it in the legal dossier. It mirrors
// account.retainIdentityTx, which is unexported; detached_at falls to the column default.
func retainMergedIdentity(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, id model.Identities) error {
rid, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("accountmerge: new retained id: %w", err)
}
ins := table.RetainedIdentities.INSERT(
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
table.RetainedIdentities.Reason,
).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainReasonMerge)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: retain merged %s identity: %w", id.Kind, err)
}
return nil
}
// friendRank ranks a friendship status for dedupe precedence (higher wins).
func friendRank(status string) int {
switch status {
+116
View File
@@ -0,0 +1,116 @@
// Package adminalert emails the operator when new player feedback or word complaints
// arrive, coalescing a burst into a single digest per interval so a flood is one email,
// not N. It is inert unless an admin sender and recipient are configured. The sender is
// distinct from the user-facing confirm-code From, and the recipient may be several
// comma-separated addresses (the mailer splits them).
package adminalert
import (
"context"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"scrabble/backend/internal/account"
)
// FeedbackCounter counts feedback created since a time (satisfied by feedback.Service).
type FeedbackCounter interface {
CountSince(ctx context.Context, since time.Time) (int, error)
}
// ComplaintCounter counts word complaints filed since a time (satisfied by game.Service).
type ComplaintCounter interface {
CountComplaintsSince(ctx context.Context, since time.Time) (int, error)
}
// Notifier polls for new feedback and complaints and emails the operator a digest.
type Notifier struct {
mailer account.Mailer
feedback FeedbackCounter
complaints ComplaintCounter
from string
to string
consoleURL string
clock func() time.Time
log *zap.Logger
last time.Time
}
// New constructs a Notifier. from and to are the alert sender and recipient(s); consoleURL,
// when non-empty, is the admin-console link included in the email. log may be nil. The
// watermark starts at "now", so only items arriving after start-up are reported.
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier {
if log == nil {
log = zap.NewNop()
}
return &Notifier{
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, consoleURL: consoleURL,
clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(),
}
}
// Run polls on each tick until ctx is cancelled.
func (n *Notifier) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
n.tick(ctx)
}
}
}
// tick counts what arrived since the last watermark and, if anything did, emails one
// digest. The watermark only advances after a successful send (or a quiet tick), so a
// transient send failure is retried on the next tick — the counts simply grow.
func (n *Notifier) tick(ctx context.Context) {
now := n.clock()
fb, err := n.feedback.CountSince(ctx, n.last)
if err != nil {
n.log.Warn("admin alert: count feedback failed", zap.Error(err))
return
}
cp, err := n.complaints.CountComplaintsSince(ctx, n.last)
if err != nil {
n.log.Warn("admin alert: count complaints failed", zap.Error(err))
return
}
if fb == 0 && cp == 0 {
n.last = now
return
}
if err := n.mailer.Send(ctx, n.digest(fb, cp)); err != nil {
n.log.Warn("admin alert: send failed", zap.Error(err))
return
}
n.log.Info("admin alert sent", zap.Int("feedback", fb), zap.Int("complaints", cp))
n.last = now
}
// digest builds the operator alert email for fb new feedback and cp new complaints.
func (n *Notifier) digest(fb, cp int) account.Message {
var parts []string
if fb > 0 {
parts = append(parts, fmt.Sprintf("%d new feedback message(s)", fb))
}
if cp > 0 {
parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp))
}
summary := strings.Join(parts, ", ")
text := summary + "."
if n.consoleURL != "" {
text += "\n\nOpen the admin console: " + n.consoleURL
}
return account.Message{
From: n.from,
To: n.to,
Subject: "Erudit — " + summary,
Text: text,
}
}
@@ -0,0 +1,55 @@
package adminalert
import (
"context"
"strings"
"testing"
"time"
"scrabble/backend/internal/account"
)
// The fakes ignore the watermark and return a fixed count, which is all the digest logic
// needs.
type fbCounter struct{ n int }
func (f fbCounter) CountSince(context.Context, time.Time) (int, error) { return f.n, nil }
type cpCounter struct{ n int }
func (c cpCounter) CountComplaintsSince(context.Context, time.Time) (int, error) { return c.n, nil }
type recordingMailer struct{ sent []account.Message }
func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
m.sent = append(m.sent, msg)
return nil
}
func TestNotifierSkipsWhenNothingNew(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", "", nil)
n.tick(context.Background())
if len(mailer.sent) != 0 {
t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent))
}
}
func TestNotifierDigestsNewItems(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", "https://erudit-game.ru/_gm", nil)
n.tick(context.Background())
if len(mailer.sent) != 1 {
t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent))
}
msg := mailer.sent[0]
if msg.From != "alerts@erudit-game.ru" || msg.To != "op@x.ru, two@x.ru" {
t.Errorf("digest addressing = From %q To %q", msg.From, msg.To)
}
if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") {
t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject)
}
if !strings.Contains(msg.Text, "/_gm") {
t.Errorf("digest body = %q, want the console link", msg.Text)
}
}
@@ -119,11 +119,11 @@
</tbody>
</table>
{{end}}
{{if not .Deleted}}{{if not .Guest}}
{{if not .Deleted}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/delete" onsubmit="return confirm('Delete this account? Its credentials are journalled and freed, its data anonymised, and its sessions revoked. This cannot be undone.')">
<button type="submit">Delete user</button>
</form>
{{end}}{{end}}
{{end}}
</section>
<section class="panel"><h2>Friends</h2>
<table class="list">
@@ -2,11 +2,12 @@
<h1>Users</h1>
{{with .Data}}
<nav class="subnav">
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
<a href="/_gm/users"{{if and (not .Robots) (not .Deleted)}} class="active"{{end}}>People</a> ·
<a href="/_gm/users?kind=deleted"{{if .Deleted}} class="active"{{end}}>Deleted</a> ·
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
</nav>
<form class="form" method="get" action="/_gm/users">
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}{{if .Deleted}}<input type="hidden" name="kind" value="deleted">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
<input name="email" value="{{.EmailExact}}" placeholder="email (exact)" type="search">
@@ -18,7 +19,7 @@
{{range .Items}}
<tr>
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
<td>{{.DisplayName}}{{if .Deleted}} <span class="pill">deleted</span>{{end}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
<td>{{.Kind}}</td>
<td>{{.Language}}</td>
<td>{{.CreatedAt}}</td>
+2
View File
@@ -60,6 +60,7 @@ type UsersView struct {
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
// percent-encoded again by the contextual escaper.
Robots bool
Deleted bool
NameMask string
ExternalIDMask string
EmailExact string
@@ -75,6 +76,7 @@ type UserRow struct {
Kind string
Language string
Guest bool
Deleted bool
FlaggedHighRate bool
CreatedAt string
HasMoveStats bool
+8 -6
View File
@@ -143,12 +143,14 @@ func Load() (Config, error) {
}
smtp := account.SMTPConfig{
Host: os.Getenv("BACKEND_SMTP_HOST"),
Port: envOr("BACKEND_SMTP_PORT", "587"),
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
TLS: os.Getenv("BACKEND_SMTP_TLS"),
Host: os.Getenv("BACKEND_SMTP_HOST"),
Port: envOr("BACKEND_SMTP_PORT", "587"),
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
TLS: os.Getenv("BACKEND_SMTP_TLS"),
AdminFrom: os.Getenv("BACKEND_SMTP_ADMIN_FROM"),
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
}
c := Config{
+5
View File
@@ -195,6 +195,11 @@ func (svc *Service) CountUnread(ctx context.Context) (int, error) {
return svc.store.CountUnread(ctx)
}
// CountSince counts feedback created after since, for the operator alert worker.
func (svc *Service) CountSince(ctx context.Context, since time.Time) (int, error) {
return svc.store.CountSince(ctx, since)
}
// Attachment returns a message's file name and bytes, reporting false when absent.
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
return svc.store.Attachment(ctx, id)
+12
View File
@@ -386,3 +386,15 @@ func (s *Store) CountUnread(ctx context.Context) (int, error) {
}
return n, nil
}
// CountSince counts feedback messages created strictly after since — the operator alert
// worker's "new since the last check" signal.
func (s *Store) CountSince(ctx context.Context, since time.Time) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM backend.feedback_messages WHERE created_at > $1`, since,
).Scan(&n); err != nil {
return 0, fmt.Errorf("feedback: count since: %w", err)
}
return n, nil
}
+6
View File
@@ -1008,6 +1008,12 @@ func (svc *Service) CountComplaints(ctx context.Context, status string) (int, er
return svc.store.CountComplaints(ctx, status)
}
// CountComplaintsSince counts word complaints filed after since, for the operator alert
// worker.
func (svc *Service) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) {
return svc.store.CountComplaintsSince(ctx, since)
}
// ResolveComplaint closes a complaint with an operator disposition (reject /
// accept_add / accept_remove) and an optional note. An accepted complaint then
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
+13
View File
@@ -1040,6 +1040,19 @@ func (s *Store) CountComplaints(ctx context.Context, status string) (int, error)
return int(dest.Count), nil
}
// CountComplaintsSince counts word complaints filed strictly after since — the operator
// alert worker's "new since the last check" signal.
func (s *Store) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) {
stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")).
FROM(table.Complaints).
WHERE(table.Complaints.CreatedAt.GT(postgres.TimestampzT(since)))
var dest struct{ Count int64 }
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("game: count complaints since: %w", err)
}
return int(dest.Count), nil
}
// ActiveGames returns the turn clocks of every in-progress game; the sweeper
// filters them against the per-move deadline and the player's away window.
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
+102
View File
@@ -134,6 +134,108 @@ func TestDeletionDossierReaders(t *testing.T) {
}
}
// listHasID reports whether the user list contains accountID.
func listHasID(items []account.UserListItem, id uuid.UUID) bool {
for _, it := range items {
if it.ID == id {
return true
}
}
return false
}
// TestUserListDeletedFilter: a tombstoned account is hidden from the default People list and
// shown only under the Deleted scope.
func TestUserListDeletedFilter(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
deleter := accountdelete.NewDeleter(testDB)
live, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Live", "")
if err != nil {
t.Fatalf("provision live: %v", err)
}
goneTg := "tg-" + uuid.NewString()
gone, _, err := store.ProvisionTelegram(ctx, goneTg, "en", "", "Gone", "")
if err != nil {
t.Fatalf("provision gone: %v", err)
}
goneEmail := "gone-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, gone.ID, account.KindEmail, goneEmail, true); err != nil {
t.Fatalf("attach gone email: %v", err)
}
if err := deleter.AnonymizeAndTombstone(ctx, gone.ID); err != nil {
t.Fatalf("delete: %v", err)
}
people, err := store.ListUsers(ctx, account.UserFilter{}, 5000, 0)
if err != nil {
t.Fatalf("list people: %v", err)
}
if listHasID(people, gone.ID) {
t.Error("a deleted account must not appear in the default People list")
}
if !listHasID(people, live.ID) {
t.Error("a live account must appear in the default People list")
}
deleted, err := store.ListUsers(ctx, account.UserFilter{Deleted: true}, 5000, 0)
if err != nil {
t.Fatalf("list deleted: %v", err)
}
if !listHasID(deleted, gone.ID) {
t.Error("a deleted account must appear in the Deleted list")
}
if listHasID(deleted, live.ID) {
t.Error("a live account must not appear in the Deleted list")
}
// A search spans both lists and reaches the retention journal: a deleted account is
// still found by the email and external id it held (both moved to retained_identities
// on deletion, out of the live identities table).
byEmail, err := store.ListUsers(ctx, account.UserFilter{EmailExact: goneEmail}, 5000, 0)
if err != nil {
t.Fatalf("search by email: %v", err)
}
if !listHasID(byEmail, gone.ID) {
t.Error("a deleted account must be found by the email it held (retention journal)")
}
byExt, err := store.ListUsers(ctx, account.UserFilter{ExternalIDMask: goneTg}, 5000, 0)
if err != nil {
t.Fatalf("search by external id: %v", err)
}
if !listHasID(byExt, gone.ID) {
t.Error("a deleted account must be found by the external id it held (retention journal)")
}
}
// TestConfirmCodeClearsGuest: confirming an email on a guest via ConfirmCode promotes it to
// a durable account (defence-in-depth — no confirmed-email path leaves is_guest set).
func TestConfirmCodeClearsGuest(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
email := "cc-" + uuid.NewString() + "@example.com"
if err := svc.RequestCode(ctx, guest.ID, email); err != nil {
t.Fatalf("request code: %v", err)
}
if _, err := svc.ConfirmCode(ctx, guest.ID, email, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("confirm code: %v", err)
}
after, err := store.GetByID(ctx, guest.ID)
if err != nil {
t.Fatalf("load: %v", err)
}
if after.IsGuest {
t.Error("confirming an email must clear the guest flag")
}
}
// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human
// opponent.
func TestDropAllRobotGames(t *testing.T) {
+99
View File
@@ -251,6 +251,44 @@ func TestAccountMergeFinishedSharedGameKept(t *testing.T) {
}
}
// TestAccountMergeDedupesEmail keeps the primary's email when both accounts have one and
// journals the secondary's to the dossier (reason=merge), so the survivor never ends up
// with two email identities.
func TestAccountMergeDedupesEmail(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
merger := accountmerge.NewMerger(testDB)
primary := provisionAccount(t)
secondary := provisionAccount(t)
primaryEmail := "keep-" + uuid.NewString() + "@example.com"
secondaryEmail := "absorb-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, primary, primaryEmail)
bindEmailIdentity(t, secondary, secondaryEmail)
if err := merger.Merge(ctx, primary, secondary); err != nil {
t.Fatalf("merge: %v", err)
}
// The primary keeps its own email; the secondary's is gone from the live identities.
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, primaryEmail); !ok || owner != primary {
t.Errorf("primary email owner = %s ok=%v, want primary %s", owner, ok, primary)
}
if _, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, secondaryEmail); ok {
t.Error("the secondary's email must be removed (no duplicate email on the survivor)")
}
// The absorbed email stays in the legal dossier, tagged reason=merge.
var reason string
if err := testDB.QueryRowContext(ctx,
`SELECT reason FROM backend.retained_identities WHERE account_id=$1 AND kind='email' AND external_id=$2`,
secondary, secondaryEmail).Scan(&reason); err != nil {
t.Fatalf("retained email row: %v", err)
}
if reason != "merge" {
t.Errorf("retained reason = %q, want merge", reason)
}
}
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
func TestAccountLinkFreeEmail(t *testing.T) {
ctx := context.Background()
@@ -355,3 +393,64 @@ func TestAccountLinkGuestInversion(t *testing.T) {
t.Errorf("email owner = %s, want durable", owner)
}
}
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
links := newLinkService(&capturingMailer{})
guest := provisionGuest(t)
vkID := "vk-" + uuid.NewString()
res, err := links.ConfirmVK(ctx, guest, vkID)
if err != nil {
t.Fatalf("confirm vk: %v", err)
}
if !res.Linked || res.MergeRequired {
t.Fatalf("confirm = %+v, want linked", res)
}
if acc, _ := store.GetByID(ctx, guest); acc.IsGuest {
t.Error("guest flag should clear once VK is linked")
}
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); !ok || owner != guest {
t.Errorf("vk owner = %s, want the promoted guest %s", owner, guest)
}
}
// TestAccountLinkVKMergeIntoCaller merges the account owning a VK identity into the
// current durable account: ConfirmVK previews the merge, MergeVK folds it, the caller
// stays primary and keeps its session, and the VK identity repoints to the caller.
func TestAccountLinkVKMergeIntoCaller(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
links := newLinkService(&capturingMailer{})
caller := provisionAccount(t)
other := provisionAccount(t)
vkID := "vk-" + uuid.NewString()
if err := store.AttachIdentity(ctx, other, account.KindVK, vkID, true); err != nil {
t.Fatalf("seed vk on other: %v", err)
}
confirm, err := links.ConfirmVK(ctx, caller, vkID)
if err != nil {
t.Fatalf("confirm vk: %v", err)
}
if !confirm.MergeRequired || confirm.SecondaryID != other {
t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
}
merge, err := links.MergeVK(ctx, caller, vkID)
if err != nil {
t.Fatalf("merge vk: %v", err)
}
if merge.PrimaryID != caller || merge.SwitchedToken != "" {
t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
}
if mergedInto(t, other) != caller {
t.Error("other should be tombstoned into caller")
}
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); owner != caller {
t.Errorf("vk owner = %s, want caller after merge", owner)
}
}
+47
View File
@@ -133,6 +133,53 @@ func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, extern
return s.accounts.ClearGuest(ctx, callerID)
}
// ConfirmVK attaches a gateway-validated VK identity to the caller (Linked) or
// reports that it belongs to another account (MergeRequired). The gateway has already
// completed the VK ID code exchange, so externalID is the trusted vk user id.
func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID string) (ConfirmResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
if err != nil {
return ConfirmResult{}, err
}
if !ok {
if err := s.attachVK(ctx, callerID, externalID); err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Linked: true}, nil
}
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
// (subject to the guest-primary rule).
func (s *Service) MergeVK(ctx context.Context, callerID uuid.UUID, externalID string) (MergeResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
if err != nil {
return MergeResult{}, err
}
if !ok {
if err := s.attachVK(ctx, callerID, externalID); err != nil {
return MergeResult{}, err
}
return MergeResult{PrimaryID: callerID}, nil
}
if owner == callerID {
return MergeResult{PrimaryID: callerID}, nil
}
return s.merge(ctx, callerID, owner)
}
// attachVK links the identity to the caller and promotes a guest.
func (s *Service) attachVK(ctx context.Context, callerID uuid.UUID, externalID string) error {
if err := s.accounts.AttachIdentity(ctx, callerID, account.KindVK, externalID, true); err != nil {
return err
}
return s.accounts.ClearGuest(ctx, callerID)
}
// merge decides the primary (the caller, unless it is a guest and the other is
// durable), runs the data merge, retires the secondary's sessions and mints a new
// session when the active account switches.
@@ -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])));
+2
View File
@@ -74,6 +74,8 @@ func (s *Server) registerRoutes() {
u.POST("/link/email/merge", s.handleLinkEmailMerge)
u.POST("/link/telegram", s.handleLinkTelegram)
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
u.POST("/link/vk", s.handleLinkVK)
u.POST("/link/vk/merge", s.handleLinkVKMerge)
u.POST("/link/unlink", s.handleUnlink)
// Change the account's confirmed email: mail a code to the new address, then
// atomically switch on confirm (a new address owned by another account is
@@ -134,6 +134,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
page := consolePage(c)
filter := account.UserFilter{
Robots: c.Query("kind") == "robots",
Deleted: c.Query("kind") == "deleted",
NameMask: c.Query("name"),
ExternalIDMask: c.Query("ext"),
EmailExact: c.Query("email"),
@@ -148,6 +149,9 @@ func (s *Server) consoleUsers(c *gin.Context) {
if filter.Robots {
q.Set("kind", "robots")
}
if filter.Deleted {
q.Set("kind", "deleted")
}
if strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
@@ -159,21 +163,24 @@ func (s *Server) consoleUsers(c *gin.Context) {
}
view := adminconsole.UsersView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
Robots: filter.Robots, Deleted: filter.Deleted, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
EmailExact: filter.EmailExact,
FilterQuery: template.URL(q.Encode()),
}
ids := make([]uuid.UUID, 0, len(items))
for _, it := range items {
kind := "registered"
if it.IsRobot {
switch {
case it.IsRobot:
kind = "robot"
} else if it.IsGuest {
case it.IsDeleted:
kind = "deleted"
case it.IsGuest:
kind = "guest"
}
view.Items = append(view.Items, adminconsole.UserRow{
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
Language: it.PreferredLanguage, Guest: it.IsGuest,
Language: it.PreferredLanguage, Guest: it.IsGuest, Deleted: it.IsDeleted,
FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt),
})
ids = append(ids, it.ID)
+48
View File
@@ -34,6 +34,12 @@ type linkTelegramBody struct {
ExternalID string `json:"external_id"`
}
// linkVKBody carries a gateway-validated VK identity (the trusted vk user id the
// gateway resolved from the VK ID code exchange).
type linkVKBody struct {
ExternalID string `json:"external_id"`
}
// linkResultResponse is the unified result of a confirm or merge step. Status is
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
// account — the secondary_* fields summarise it for the irreversible confirmation),
@@ -233,6 +239,48 @@ func (s *Server) handleLinkTelegramMerge(c *gin.Context) {
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
}
// handleLinkVK attaches a gateway-validated VK identity to the caller or reports a
// required merge.
func (s *Server) handleLinkVK(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkVKBody
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "missing external_id")
return
}
res, err := s.links.ConfirmVK(c.Request.Context(), uid, req.ExternalID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
}
// handleLinkVKMerge merges the account owning a gateway-validated VK identity into
// the caller's.
func (s *Server) handleLinkVKMerge(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkVKBody
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "missing external_id")
return
}
res, err := s.links.MergeVK(c.Request.Context(), uid, req.ExternalID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
}
// confirmResultResponse renders a confirm step: a merge preview (secondary summary)
// or a completed link (the active account's refreshed profile).
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
+34 -5
View File
@@ -1,6 +1,10 @@
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; the prod
# deploy maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run.
# deploy maps the PROD_-prefixed set the same way. Values that are identical on every
# contour (DICT_VERSION, SMTP_RELAY_HOST/PORT/TLS/USER/PASS, GRAFANA_SMTP_PORT,
# VITE_VK_APP_LINK/ID, the two VK secrets) live as ONE unprefixed Gitea entry, and the
# deploy derives TELEGRAM_MINIAPP_URL / GRAFANA_ROOT_URL / VITE_VK_ID_REDIRECT_URL from
# PUBLIC_BASE_URL. Copy to deploy/.env for a local run (set the derived ones directly).
#
# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
@@ -15,8 +19,9 @@ POSTGRES_PASSWORD=change-me # required
# boot the dawg-data volume preserves versions uploaded through the admin console and
# the active version lives in the DB. On a live volume a changed value is ignored (the
# recorded .seed_version marker wins — the seed-drift guard); change a running
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
DICT_VERSION=v1.3.0
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). One shared Gitea
# variable (DICT_VERSION) seeds both contours + pins the CI test suite.
DICT_VERSION=v1.3.1
# --- Logging ----------------------------------------------------------------
LOG_LEVEL=info
@@ -45,6 +50,17 @@ SMTP_RELAY_PASS= # secret
SMTP_RELAY_FROM=no-reply@erudit-game.ru
PUBLIC_BASE_URL= # required when SMTP_RELAY_HOST is set (e.g. https://erudit-game.ru)
# Operator alerts (email). The backend emails the admin on new feedback / word complaints
# (coalesced), and Grafana emails infra alerts. Distinct senders; recipients may be several
# comma-separated addresses. Grafana reuses SMTP_RELAY_HOST/USER/PASS but dials the STARTTLS
# port (it can't do the backend's implicit TLS), GRAFANA_SMTP_PORT. All empty = off.
SMTP_RELAY_ADMIN_FROM= # backend admin-alert From (e.g. alerts@erudit-game.ru)
ADMIN_EMAIL= # backend admin-alert recipient(s), comma-separated
SMTP_RELAY_SERVICE_FROM= # Grafana alert From; the deploy derives a bare address for Grafana (it rejects "Name" <addr>)
SERVICE_EMAIL= # Grafana alert recipient(s), comma-separated
GRAFANA_SMTP_PORT= # Grafana STARTTLS port on SMTP_RELAY_HOST (Selectel: 1126)
GF_SMTP_ENABLED=false # set true to enable Grafana alert emails
# --- Edge / caddy -----------------------------------------------------------
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
# external `edge` network). Prod: a domain so caddy does its own ACME.
@@ -57,10 +73,12 @@ VITE_TELEGRAM_BOT_ID=
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
VITE_VK_ID_REDIRECT_URL= # VK ID trusted redirect URL (e.g. https://erudit-game.ru/app/); also the gateway's exchange redirect_uri. Deploy derives it as PUBLIC_BASE_URL + /app/
VITE_GATEWAY_URL=
# --- Grafana ----------------------------------------------------------------
GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain
GRAFANA_ROOT_URL=/_gm/grafana/ # deploy derives PUBLIC_BASE_URL + /_gm/grafana/; set the full https URL for a local run
GRAFANA_ADMIN_PASSWORD=admin
# --- Telegram validator + bot -----------------------------------------------
@@ -77,7 +95,7 @@ TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; emp
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
TELEGRAM_MINIAPP_URL= # required
TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_BASE_URL + /telegram/
TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL=
@@ -86,3 +104,14 @@ TELEGRAM_API_BASE_URL=
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
GATEWAY_VK_APP_SECRET=
# VK ID web login (browser VK-identity linking) — a SEPARATE VK "Web" app: the gateway runs
# the confidential OAuth 2.1 code exchange under this protected key. The app id + redirect URL
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
GATEWAY_VK_ID_CLIENT_SECRET=
# --- Gateway anti-abuse ------------------------------------------------------
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
# where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value
# somewhere an attacker would find it; empty disables the trap. Gitea
# TEST_/PROD_GATEWAY_HONEYTOKEN (secret).
GATEWAY_HONEYTOKEN=
+71 -26
View File
@@ -46,6 +46,18 @@ runs `docker compose up -d --build` on the runner host. The prod deploy maps the
**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
feeds the compose's `POSTGRES_PASSWORD`, etc.
Three naming classes in Gitea:
- **Per-contour** (`TEST_`/`PROD_<NAME>`) — values that differ between the contours
(bots, hosts, public origin, log level, email senders): the common case below.
- **Shared** (one unprefixed `<NAME>`, no prefix) — values identical on every contour,
stored once: `DICT_VERSION`, `SMTP_RELAY_HOST`/`PORT`/`TLS`/`USER`/`PASS`,
`GRAFANA_SMTP_PORT`, `VITE_VK_APP_LINK`, `VITE_VK_APP_ID`, `GATEWAY_VK_APP_SECRET`,
`GATEWAY_VK_ID_CLIENT_SECRET` (one Selectel relay + one pair of VK apps for all contours).
- **Derived** — not stored at all; the deploy computes them from `PUBLIC_BASE_URL`
(`deploy/write-prod-env.sh`, and the `ci.yaml` deploy step): `TELEGRAM_MINIAPP_URL`
(`+ /telegram/`), `GRAFANA_ROOT_URL` (`+ /_gm/grafana/`), `VITE_VK_ID_REDIRECT_URL`
(`+ /app/`). Set them directly only for a local `.env` run.
The deploy job also **seeds the config files** (`caddy`, `otelcol`, `prometheus`,
`tempo`, `grafana`) to a stable host path (`$HOME/.scrabble-deploy`) and sets
`SCRABBLE_CONFIG_DIR` to it before `up`. The runner's checkout is an ephemeral act
@@ -62,7 +74,7 @@ compose binds from this directory.
| --- | --- | --- |
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the bot hands out in deep links / buttons. |
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
**Plus the bot token**`TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
@@ -82,11 +94,11 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| --- | --- | --- | --- |
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
| `DICT_VERSION` | variable | `v1.3.0` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
| `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https://<domain>/_gm/grafana/` behind a real domain. |
| `GRAFANA_ROOT_URL` | derived | `/_gm/grafana/` | Grafana root URL (sub-path serving). The deploy derives `PUBLIC_BASE_URL + /_gm/grafana/`; set the full `https://<domain>/_gm/grafana/` only for a local run. |
| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
| `TELEGRAM_GAME_CHANNEL_ID` | variable | _(empty)_ | The bot's game-channel id; empty/`0` disables channel posts. |
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
@@ -94,15 +106,26 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). |
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
| `VITE_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). |
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
| `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. |
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. |
| `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
| `SMTP_RELAY_HOST` | variable | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
| `SMTP_RELAY_PORT` | variable | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
| `SMTP_RELAY_TLS` | variable | _(empty)_ | Transport security: `ssl` (implicit TLS) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); required for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). |
| `SMTP_RELAY_USER` | secret | _(empty)_ | Relay SMTP AUTH username. |
| `SMTP_RELAY_PASS` | secret | _(empty)_ | Relay SMTP AUTH password. |
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
| `SMTP_RELAY_TLS` | variable (shared) | _(empty)_ | Transport security: `ssl` (implicit TLS) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); required for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). |
| `SMTP_RELAY_USER` | secret (shared) | _(empty)_ | Relay SMTP AUTH username. |
| `SMTP_RELAY_PASS` | secret (shared) | _(empty)_ | Relay SMTP AUTH password. |
| `SMTP_RELAY_FROM` | variable | `no-reply@localhost` | Sender address. **Must use the prod domain** (`no-reply@erudit-game.ru`) — Selectel only accepts the verified sender domain — so it is the same on every contour. |
| `PUBLIC_BASE_URL` | variable | _(empty)_ | Canonical public https origin for links in the email (the contour's own URL, e.g. `https://erudit-game.ru`). **Required by the backend whenever `SMTP_RELAY_HOST` is set** — it is never derived from a request Host header (anti-injection). |
| `SMTP_RELAY_ADMIN_FROM` | variable | _(empty)_ | Backend operator-alert sender (new feedback / word complaints), distinct from the confirm-code From. Empty (with `ADMIN_EMAIL`) disables the alert worker. |
| `ADMIN_EMAIL` | variable | _(empty)_ | Backend operator-alert recipient(s); several comma-separated addresses allowed. |
| `SMTP_RELAY_SERVICE_FROM` | variable | _(empty)_ | Grafana infra-alert sender address. |
| `SERVICE_EMAIL` | variable | _(empty)_ | Grafana infra-alert recipient(s); comma-separated allowed (read by the provisioned contact point via `$__env{SERVICE_EMAIL}`). |
| `GRAFANA_SMTP_PORT` | variable (shared) | _(empty)_ | STARTTLS port Grafana dials on `SMTP_RELAY_HOST` (its client can't do the backend's implicit TLS), e.g. Selectel's `1126`. Reuses `SMTP_RELAY_HOST`/`USER`/`PASS`. |
| `GF_SMTP_ENABLED` | variable | `false` | Set `true` to enable Grafana alert emails. |
The six `VITE_*` are **build-args** baked into the gateway and landing images at
build time (both targets share one UI build stage — keep the args identical so it is
@@ -132,14 +155,13 @@ intentionally **unset** — caddy owns `/_gm` in the contour.
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
a build-time input with **no default** in the images, so it is set in exactly two places to
move the whole stack — change both to a new release:
a build-time input with **no default** in the images. It is a single Gitea repo variable —
**`DICT_VERSION`** (unprefixed, shared across contours) — so a release bump is **one edit**:
1. **CI tests** `.gitea/workflows/ci.yaml` `env.DICT_VERSION` (the unit/integration jobs
download that dawg).
2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag
the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as
`DICT_VERSION`).
- the CI test jobs read it via `.gitea/workflows/ci.yaml`'s top-level
`env.DICT_VERSION: ${{ vars.DICT_VERSION }}` (the unit/integration jobs download that dawg), and
- both deploy jobs feed the same variable to `compose` as the `DICT_VERSION` build-arg that
bakes a **fresh** volume's seed.
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
@@ -168,6 +190,16 @@ public site. After `master` is green this workflow is the **only** thing that to
prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main →
deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
**Maintenance page.** During the roll (and any migration window) `prod-deploy.sh` raises a
flag the edge caddy serves a static 503 "технические работы" page from
(`deploy/caddy/maintenance.html`), for the user-facing routes only — `/_gm` (Grafana) stays
reachable. The flag is cleared on any exit (success, a health failure + rollback, or an
error) by a shell trap, so it can never stick on. It arms from this feature's own deploy
onward (the caddy carrying the gate must be live first). This is **not** a zero-downtime
deploy — the backend is a single stateful instance (in-memory game state + push hub, no
Redis), so its swap still blips (clients auto-reconnect the live stream); the page just
makes the window graceful instead of raw 502s.
**Versioning.** Each release is a git tag `vX.Y.Z` on `master`; the deploy stamps
`git describe --tags` into every image tag, every binary (`-ldflags``pkg/version`
the `service.version` telemetry attribute) and the SPA About screen. Tag the release
@@ -200,18 +232,31 @@ together with the fresh CA.
**Sizing / monitoring:** the main host launches undersized (2 vCPU / 1.9 GiB); the prod
overlay trims limits + `GOMAXPROCS=2` + 7d Prometheus retention, and `node_exporter` feeds
host memory to Grafana (`/_gm/grafana/`). Watch host memory and resize at Selectel when
players arrive.
players arrive. The per-container memory caps are enforced (Compose v2 → cgroup), so no one
service can eat all RAM, but they **overcommit** the host (~2.8 GiB of caps vs 1.9 GiB) — a
**1 GiB swap file** (Ansible `common` role, `swap_size`, `vm.swappiness=10`) cushions a
simultaneous spike into swap instead of the kernel OOM-killer. The `host_mem_low` Grafana
alert fires under 10% available.
**`PROD_` Gitea set** (mirrors `TEST_`, mapped onto the unprefixed names above) — secrets:
**Shared Gitea set** (one unprefixed entry each, used by every contour) — secrets:
`GATEWAY_VK_APP_SECRET, GATEWAY_VK_ID_CLIENT_SECRET, SMTP_RELAY_USER, SMTP_RELAY_PASS`;
variables: `DICT_VERSION, SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, GRAFANA_SMTP_PORT,
VITE_VK_APP_LINK, VITE_VK_APP_ID`. **Derived from `PUBLIC_BASE_URL` at deploy** (not stored):
`TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL, VITE_VK_ID_REDIRECT_URL`.
**`PROD_` Gitea set** (per-contour, mirrors `TEST_`, mapped onto the unprefixed names above)
— secrets:
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY,
SMTP_RELAY_USER, SMTP_RELAY_PASS}`; variables:
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK,
SMTP_RELAY_HOST, SMTP_RELAY_PORT, SMTP_RELAY_TLS, SMTP_RELAY_FROM, PUBLIC_BASE_URL}`.
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, GATEWAY_HONEYTOKEN, REGISTRY_PASSWORD, SSH_KEY,
SSH_KNOWN_HOSTS, BOTLINK_CA, BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT,
BOTLINK_BOT_KEY}`; variables:
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, LOG_LEVEL,
TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_BOT_USERNAME,
VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, VITE_TELEGRAM_GAME_CHANNEL_NAME, SMTP_RELAY_FROM,
PUBLIC_BASE_URL, SMTP_RELAY_ADMIN_FROM, ADMIN_EMAIL, SMTP_RELAY_SERVICE_FROM, SERVICE_EMAIL,
GF_SMTP_ENABLED}`. The test contour uses the same names under `TEST_`, minus the prod-only
infra (`MAIN_HOST`/`TG_HOST`/`REGISTRY_*`/`SSH_*`/`BOTLINK_*`, which the test deploy generates
or runs locally) and plus `TEST_AWG_CONF` (the bot's VPN egress).
## Host-side setup (outside this repo)
+8
View File
@@ -19,3 +19,11 @@ scrabble_base_dir: /opt/scrabble
# the host's own containers (and any ad-hoc runs) rotate identically.
docker_log_max_size: "10m"
docker_log_max_file: "3"
# Swap file as an OOM cushion. The per-container memory caps (docker-compose.prod.yml)
# sum to more than the tight main host's RAM (2 vCPU / 1.9 GiB), so a simultaneous
# spike could otherwise hit the kernel OOM-killer and it might pick postgres. A small
# swap absorbs the overshoot; swappiness stays low so swap is a cushion, not a hot
# path (a slow service beats a killed one). Set swap_size to "0" to skip provisioning.
swap_size: "1G"
swap_swappiness: 10
@@ -150,6 +150,57 @@
enabled: true
state: started
# --- Swap file (OOM cushion) ---------------------------------------------------
# The prod main host is tight (1.9 GiB) and the per-container memory caps
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
# the kernel OOM-killer (which might pick postgres). A small swap absorbs the
# overshoot. Idempotent; skipped when swap_size == "0". Builtin-only (no ansible.posix).
- name: Check whether the swap file is already active
ansible.builtin.command: swapon --show=NAME --noheadings
register: swap_active
changed_when: false
when: swap_size != "0"
- name: Allocate the swap file
ansible.builtin.command:
cmd: "fallocate -l {{ swap_size }} /swapfile"
creates: /swapfile
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
- name: Secure the swap file (0600)
ansible.builtin.file:
path: /swapfile
owner: root
group: root
mode: "0600"
when: swap_size != "0"
- name: Format and enable the swap file
ansible.builtin.shell: "mkswap /swapfile && swapon /swapfile"
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
- name: Persist the swap file in /etc/fstab
ansible.builtin.lineinfile:
path: /etc/fstab
line: "/swapfile none swap sw 0 0"
regexp: '^/swapfile\s'
state: present
when: swap_size != "0"
- name: Keep swap a cushion, not a hot path (low swappiness)
ansible.builtin.copy:
dest: /etc/sysctl.d/60-scrabble-swap.conf
mode: "0644"
content: "vm.swappiness = {{ swap_swappiness }}\n"
register: swappiness_conf
when: swap_size != "0"
- name: Apply swappiness now
ansible.builtin.command: sysctl --system
changed_when: false
when: swap_size != "0" and swappiness_conf is changed
# --- Deploy directories --------------------------------------------------------
- name: Create the scrabble base directories
+14
View File
@@ -0,0 +1,14 @@
# blackbox_exporter probe modules. tls_cert opens a verified TLS connection to the edge
# caddy so Prometheus can read probe_ssl_earliest_cert_expiry — the signal behind the
# certificate-renewal-failure alert. The SNI is the production public host, whose cert the
# edge caddy serves once it does its own ACME; on the test contour caddy is HTTP-only
# (behind the host caddy), so the probe finds nothing on :443 and the cert metric is absent.
modules:
tls_cert:
prober: tcp
timeout: 5s
tcp:
tls: true
tls_config:
server_name: erudit-game.ru
insecure_skip_verify: false
+34 -1
View File
@@ -33,6 +33,39 @@
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
header Alt-Svc clear
# Maintenance gate. While the deploy holds the flag file /srv/maint/on (touched by
# prod-deploy.sh around a rolling swap, removed on the script's exit), serve a static
# 503 "works in progress" page instead of proxying to a mid-recreate upstream —
# a graceful signal rather than raw 502s. Operator surfaces (/_gm) are excluded so
# Grafana stays reachable during the window. Caddy stat()s the flag per request, so
# toggling it needs no reload; the page + flag live in the caddy config dir bind-mounted
# read-only at /srv/maint (docker-compose.yml). The test contour never sets the flag
# (only prod-deploy.sh does), so this is inert there.
@maintenance {
not path /_gm /_gm/*
file {
root /srv/maint
try_files on
}
}
handle @maintenance {
error 503
}
handle_errors {
@maint503 expression {err.status_code} == 503
handle @maint503 {
root * /srv/maint
rewrite * /maintenance.html
header Retry-After 120
# Distinctive maintenance marker so the SPA can tell a planned window apart from
# a transient upstream 503 and show its own dimmed overlay (an in-session user
# never sees this static page — it only renders on a fresh load). The header
# rides every gated response, incl. the Connect/gRPC edge the app polls.
header X-Scrabble-Maintenance 1
file_server
}
}
# Operator surfaces under /_gm: a single shared Basic-Auth, then route.
@gm path /_gm /_gm/*
handle @gm {
@@ -53,7 +86,7 @@
# The game SPA and the Connect edge are served by the gateway. Strip any
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
# tag the honeypot block sets below (a client cannot self-tag a real request).
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /scrabble.edge.v1.Gateway/*
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
handle @gateway {
reverse_proxy gateway:8081 {
header_up -X-Scrabble-Honeypot
+44
View File
@@ -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>
+60 -4
View File
@@ -151,6 +151,10 @@ services:
BACKEND_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-}
BACKEND_SMTP_FROM: ${SMTP_RELAY_FROM:-no-reply@localhost}
BACKEND_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
# Operator alert emails (new feedback / word complaints): a distinct sender and the
# recipient(s) (comma-separated allowed). Both empty disables the alert worker.
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-}
# The dictionary lives on a named volume seeded from the image on first boot
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
# inherits). The admin console writes new version subdirectories here, and the
@@ -186,6 +190,8 @@ services:
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev}
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
@@ -203,6 +209,14 @@ services:
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
# the VK auth path (auth.vk is then unregistered).
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
# VK ID web login (browser VK-identity linking): the confidential OAuth 2.1 code
# exchange runs server-side under the VK ID "Web" app's protected key. This is a
# SEPARATE VK app from the Mini App above, so the credentials are distinct. The app id
# and redirect URL are the same values the SPA builds its authorize URL from (one
# source each). All three empty disables the link.vk.* ops.
GATEWAY_VK_ID_APP_ID: ${VITE_VK_APP_ID:-}
GATEWAY_VK_ID_CLIENT_SECRET: ${GATEWAY_VK_ID_CLIENT_SECRET:-}
GATEWAY_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
# listeners stay on the internal network (the bot shares the VPN netns); in prod
@@ -213,10 +227,12 @@ services:
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
# Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the
# honeypot/honeytoken. Off by default: it bans by client IP, which is only
# real in prod — the test contour arrives as one shared NAT address, so a ban
# there would be self-inflicted (the honeypot/honeytoken still log). Prod sets
# these from PROD_ inputs; GATEWAY_HONEYTOKEN is the planted bearer trap.
# honeypot/honeytoken. Off by default: it bans by client IP, which is only real
# in prod — the test contour arrives as one shared NAT address, so a ban there
# would be self-inflicted (the honeypot still logs). The prod deploy forces it on
# in env.sh (deploy/write-prod-env.sh), not via a Gitea variable. GATEWAY_HONEYTOKEN
# is the planted bearer trap, fed from the per-contour TEST_/PROD_GATEWAY_HONEYTOKEN
# secret; empty (unset secret) leaves the trap off.
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
@@ -260,6 +276,8 @@ services:
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
VITE_VK_APP_ID: ${VITE_VK_APP_ID:-}
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev}
restart: unless-stopped
@@ -410,6 +428,11 @@ services:
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
# Maintenance page + toggle flag: the caddy config dir holds maintenance.html and the
# `on` flag prod-deploy.sh touches around a rolling swap; the Caddyfile serves a 503
# from here while the flag exists (read-only mount — the deploy writes the flag on the
# host side). See deploy/caddy/Caddyfile and deploy/prod-deploy.sh.
- ${SCRABBLE_CONFIG_DIR:-.}/caddy:/srv/maint:ro
- caddy-data:/data
deploy:
resources:
@@ -497,6 +520,21 @@ services:
# caddy's Basic-Auth and re-prompts for the password on every dashboard; the
# dashboards poll and do not need Live.
GF_LIVE_MAX_CONNECTIONS: "0"
# SMTP for alert emails, reusing the shared relay host + credentials + the SERVICE
# From/recipient. Grafana's client speaks STARTTLS (not the backend's implicit-TLS
# port), so it dials the relay host on its STARTTLS port (GRAFANA_SMTP_PORT).
# Disabled unless GF_SMTP_ENABLED.
GF_SMTP_ENABLED: ${GF_SMTP_ENABLED:-false}
GF_SMTP_HOST: ${SMTP_RELAY_HOST:-}:${GRAFANA_SMTP_PORT:-}
GF_SMTP_USER: ${SMTP_RELAY_USER:-}
GF_SMTP_PASSWORD: ${SMTP_RELAY_PASS:-}
# A BARE address (Grafana rejects the "Name" <addr> form); the deploy derives it from
# SMTP_RELAY_SERVICE_FROM, splitting off the display name into GRAFANA_SMTP_FROM_NAME.
GF_SMTP_FROM_ADDRESS: ${GRAFANA_SMTP_FROM_ADDRESS:-}
GF_SMTP_FROM_NAME: ${GRAFANA_SMTP_FROM_NAME:-Erudit Alerts}
GF_SMTP_STARTTLS_POLICY: MandatoryStartTLS
# The alert recipient(s), read by the provisioned contact point via $__env{SERVICE_EMAIL}.
SERVICE_EMAIL: ${SERVICE_EMAIL:-}
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro
# Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the
@@ -547,6 +585,24 @@ services:
memory: 64M
networks: [internal]
# blackbox_exporter lets Prometheus alert on TLS certificate expiry (a Caddy ACME
# renewal failure) via probe_ssl_earliest_cert_expiry. It probes the edge caddy that
# terminates TLS (prod: the published scrabble-caddy; the test contour has no compose
# caddy, so the probe simply finds no target and the cert metric is absent — the rule is
# absent-safe). See prometheus.yml and grafana alerting rules.
blackbox_exporter:
container_name: scrabble-blackbox-exporter
image: prom/blackbox-exporter:v0.25.0
restart: unless-stopped
logging: *default-logging
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
deploy:
resources:
limits:
memory: 64M
networks: [internal, edge]
networks:
internal:
name: scrabble-internal
+16
View File
@@ -56,6 +56,22 @@
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }]
},
{
"type": "timeseries",
"title": "Unsupported-engine screens (cumulative by reason)",
"description": "Clients turned away by the index.html boot guard because the engine cannot run the app (an old Android System WebView), by reason: no_bigint / no_proxy (a missing unpolyfillable primitive) or boot_error (an uncaught startup failure). Deduped per device/version, so this counts distinct blocked installs, not launches. The effective floor is Chrome 67 (BigInt).",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 32 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(unsupported_engine_total) by (reason)", "legendFormat": "{{reason}}" }]
},
{
"type": "timeseries",
"title": "Unsupported engines by Chromium (rate)",
"description": "The same blocked clients by reported Chromium major version — which old in-app WebViews are still in the wild. \"other\" is an unparseable or out-of-range version.",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 32 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(unsupported_engine_total[1h])) by (chromium)", "legendFormat": "Chromium {{chromium}}" }]
}
]
}
@@ -0,0 +1,13 @@
# Grafana alerting contact point: the operator's alert mailbox, read from the
# SERVICE_EMAIL container env (see docker-compose.yml grafana), so it stays per-contour.
# SERVICE_EMAIL may hold several comma-separated addresses.
apiVersion: 1
contactPoints:
- orgId: 1
name: ops-email
receivers:
- uid: ops_email
type: email
settings:
addresses: $__env{SERVICE_EMAIL}
singleEmail: true
@@ -0,0 +1,10 @@
# Notification policy: every alert routes to the operator email, grouped so a burst is one
# message, with a 4-hour re-notify while still firing.
apiVersion: 1
policies:
- orgId: 1
receiver: ops-email
group_by: ['alertname']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
@@ -0,0 +1,214 @@
# Grafana provisioned alert rules for the Scrabble contour. Each rule is a Prometheus
# instant query (refId A) fed into a threshold expression (refId C). noDataState/execErrState
# are OK so an absent metric never raises a false alert — notably cert-expiry, whose blackbox
# probe has no target on the test contour (caddy is HTTP-only there). Metric names are the
# real ones Prometheus exposes (edge_request_* from the gateway via the collector,
# node_*/pg_*/probe_ssl_* from the exporters). All route to the ops-email contact point.
apiVersion: 1
groups:
- orgId: 1
name: scrabble-service
folder: Alerts
interval: 1m
rules:
- uid: svc_target_down
title: Scrape target down
condition: C
for: 3m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: up, instant: true }
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: lt, params: [1] }
labels: { severity: critical }
annotations: { summary: 'A Prometheus scrape target is down (up < 1).' }
- uid: edge_error_rate
title: Gateway internal-error rate high
condition: C
for: 10m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 600, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: sum by (service_name) (rate(edge_request_duration_count{result="internal"}[5m]))
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [0.05] }
labels: { severity: warning }
annotations: { summary: 'Sustained internal (5xx-equivalent) errors at the edge.' }
- uid: edge_latency_p99
title: Gateway request latency p99 high
condition: C
for: 10m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 600, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: histogram_quantile(0.99, sum by (le, service_name) (rate(edge_request_duration_bucket[5m])))
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [1] }
labels: { severity: warning }
annotations: { summary: 'Edge request p99 latency above 1s.' }
- uid: tls_cert_expiry
title: TLS certificate nearing expiry
condition: C
for: 15m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: (probe_ssl_earliest_cert_expiry - time()) / 86400
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: lt, params: [20] }
labels: { severity: critical }
annotations: { summary: 'Edge TLS cert has under 20 days left — Caddy ACME renewal may have failed.' }
- orgId: 1
name: scrabble-host
folder: Alerts
interval: 1m
rules:
- uid: host_mem_low
title: Host memory low
condition: C
for: 5m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: lt, params: [0.1] }
labels: { severity: critical }
annotations: { summary: 'Under 10% host memory available.' }
- uid: host_disk_low
title: Host disk low
condition: C
for: 10m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: min(node_filesystem_avail_bytes / node_filesystem_size_bytes)
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: lt, params: [0.1] }
labels: { severity: critical }
annotations: { summary: 'A host filesystem is under 10% free.' }
- uid: host_cpu_high
title: Host CPU saturated
condition: C
for: 10m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 600, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: 1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m]))
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [0.9] }
labels: { severity: warning }
annotations: { summary: 'Host CPU above 90% for 10 minutes.' }
- uid: pg_connections_high
title: Postgres connections high
condition: C
for: 5m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: sum(pg_stat_activity_count) / max(pg_settings_max_connections)
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [0.8] }
labels: { severity: warning }
annotations: { summary: 'Postgres using over 80% of max_connections.' }
+16
View File
@@ -42,6 +42,15 @@ PREV_STATE_FILE="${PREV_STATE_FILE:-/opt/scrabble/PREVIOUS_TAG}"
PG_USER="${POSTGRES_USER:-scrabble}"
PG_DB="${POSTGRES_DB:-scrabble}"
# Edge maintenance page. caddy serves a static 503 "works in progress" page for the
# user-facing routes while this flag file exists (deploy/caddy/Caddyfile). We hold it
# across the roll / migration window and clear it on ANY exit — success, a health
# failure + rollback, or an unexpected error — via the trap, so users get a graceful
# page instead of raw mid-swap 502s and the flag can never get stuck on.
MAINT_FLAG="${MAINT_FLAG:-${SCRABBLE_CONFIG_DIR:-/opt/scrabble}/caddy/on}"
maint_off() { rm -f "$MAINT_FLAG" 2>/dev/null || true; }
trap maint_off EXIT
cd "$COMPOSE_DIR" || { echo "compose dir $COMPOSE_DIR missing"; exit 1; }
export REGISTRY
# otelcol joins the host docker group to read the socket; the GID varies per host.
@@ -119,6 +128,13 @@ if [ -z "$(docker ps -aq -f name=scrabble-backend)" ]; then
exit 0
fi
# Existing stack: raise the maintenance page for the whole roll (and any migration
# window). It shows once caddy carries the gate (from this feature's own deploy
# onward); the EXIT trap lowers it however this run ends.
mkdir -p "$(dirname "$MAINT_FLAG")"
: > "$MAINT_FLAG"
echo "maintenance page raised ($MAINT_FLAG)"
# Migration deploy: freeze writes and snapshot a consistent dump before migrating.
if [ "$MIGRATION" = 1 ]; then
echo "migration deploy: opening maintenance window (stopping the backend = the only writer)"
+18
View File
@@ -23,3 +23,21 @@ scrape_configs:
- job_name: node
static_configs:
- targets: ["node_exporter:9100"]
# TLS certificate expiry of the edge caddy (probe_ssl_earliest_cert_expiry), for the
# certificate-renewal-failure alert. Effective on prod, where caddy terminates TLS on
# :443; on the test contour caddy is HTTP-only, so the probe finds nothing and the metric
# is absent (the alert rule is absent-safe). The exporter probes the target passed as a
# scrape parameter and answers on its own :9115.
- job_name: blackbox_tls
metrics_path: /probe
params:
module: [tls_cert]
static_configs:
- targets: ["caddy:443"]
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox_exporter:9115
+31
View File
@@ -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
+76
View File
@@ -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
+39 -4
View File
@@ -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
flow, **Telegram** through the web **Login Widget** (validated by the validator,
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
passes the trusted `external_id` to the backend, as for `auth.telegram`). The
passes the trusted `external_id` to the backend, as for `auth.telegram`), and **VK**
through **VK ID web login** (`gateway/internal/vkid`): the browser runs the VK ID raw
OAuth 2.1 flow with PKCE — a full-page redirect to VK's hosted login, no `@vkid/sdk`
and the gateway completes the **confidential code exchange** server-side under the VK
"Web" app's protected key (a distinct VK app from the Mini App) to obtain the trusted
`external_id`. A browser has no signed launch parameters, so unlike the VK Mini App
(offline HMAC, §12) this makes an outbound call to `id.vk.com`, and it is web-only — a
full-page redirect would strand a native Mini App webview. The
request step **always** sends/accepts the proof (no pre-send "already taken"
signal, so a probe cannot enumerate registered addresses); a required **merge**
is revealed **only after** the proof is verified and is performed behind an
explicit, irreversible confirmation. A free identity is simply attached (and a
explicit, irreversible confirmation (for VK, whose authorization code is single-use,
the merge re-authorizes for a fresh code). A free identity is simply attached (and a
guest is promoted to durable, clearing `is_guest`).
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
@@ -284,8 +292,11 @@ arrive from a platform rather than completing a mandatory registration).
invitations / friend-codes / drafts / pending-codes. **Chat, feedback and complaints are
kept.** The orchestration a layer up resigns the account's active games (so opponents are
not stranded), **drops its all-robot games** (no human opponent; children cascade), and
revokes its sessions. Every credential detachment — **unlink, email change and delete**
writes a `retained_identities` row (`reason`), so the dossier keeps the full timeline.
revokes its sessions. Every credential detachment — **unlink, email change, delete and a
merge collision** — writes a `retained_identities` row (`reason`), so the dossier keeps the
full timeline. (A merge that would otherwise leave the survivor with two identities of one
kind — e.g. each account held a confirmed email — keeps the primary's and journals the
secondary's with `reason=merge` before dropping it.)
Step-up is a mailed code (`purpose=delete`, no deeplink — a stray click must not delete;
`ConfirmByToken` refuses a delete token) for an email account, else a typed phrase.
`last_login_at` / `last_login_ip` are stamped on the cold-load profile fetch (throttled
@@ -1003,6 +1014,19 @@ browser), so an open in-app session reflects it at once.
both are surfaced on the **Scrabble — Resources** Grafana dashboard, which captures the
stress-run resource profile. (`docker_stats` replaced cAdvisor, which on the contour
host resolved only the root cgroup — a separate-XFS `/var/lib/docker`.)
- **Alerting.** Grafana emails infra alerts through the shared relay (its own SMTP on the
STARTTLS port) to `SERVICE_EMAIL`, from provisioned rules
(`deploy/grafana/provisioning/alerting/`): a scrape-target down, the gateway's
internal-error rate and p99 latency (`edge_request_*`), host memory/disk/CPU
(node_exporter), Postgres connection saturation (postgres_exporter), and **TLS certificate
expiry < 20 days** — a Caddy ACME-renewal-failure signal from a **`blackbox_exporter`**
probe of the edge caddy (`probe_ssl_earliest_cert_expiry`). Every rule is `noDataState=OK`,
so an absent metric never false-alerts — notably the cert probe, which has no TLS target on
the HTTP-only contour caddy. Separately, the backend's **admin-alert worker**
(`internal/adminalert`, started from `main`) emails the operator (`ADMIN_EMAIL`, from a
distinct `SMTP_RELAY_ADMIN_FROM`) when new player feedback or word complaints arrive,
**coalescing** a burst into one digest per interval; both recipients may be several
comma-separated addresses. Both paths are inert unless configured.
- Per-request server-side timing via gin middleware from day one (the access log
carries method, route, status, latency and the active trace id). A
client-measured RTT piggybacked on the next request is a later enhancement.
@@ -1046,6 +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
design (a dropped beacon just retries its batch on the next flush); it is telemetry, never
a game input.
- **Unsupported-engine screens (client-reported):** the ES5 boot guard in `index.html` (§13)
shows a full-screen "your device's OS or browser can't run the app" screen — instead of a white
screen — when the engine lacks an unpolyfillable essential (`BigInt`, the 64-bit FlatBuffers
decode; or `Proxy`, Svelte 5 runes) or an uncaught error aborts boot; the effective floor is
Chrome 67. It then fires one fire-and-forget beacon (`POST /telemetry/unsupported`
unauthenticated, since the client never booted, but per-IP public-limited and body-capped),
deduped in `localStorage` by app version + reason + Chromium so a user reopening the app is one
report. The gateway folds it into `unsupported_engine_total` (`reason` =
no_bigint/no_proxy/boot_error/other; `chromium` = the major version reduced to a bounded range so
a spoofed beacon cannot inflate cardinality) and logs the full user agent (not a label). Surfaced
on the **Scrabble — Users** dashboard beside app opens.
- **Rate-limit observability:** every limiter rejection increments the gateway
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
+4 -2
View File
@@ -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.
The profile lists the account's **sign-in methods**. On the web a player can add
Telegram; adding VK on the web is not offered yet, and inside a Mini App the host
platform is already linked. A linked provider can be **unlinked** — except the last
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
back); inside a Mini App the host platform is already linked. Linking a provider that
already belongs to another account offers the same irreversible **merge** as email
linking. A linked provider can be **unlinked** — except the last
remaining sign-in method, which is refused so the account stays reachable. **Email is
never unlinked; it is changed**: the player enters a new address, confirms a code
mailed to it, and the account switches to it atomically, freeing the old address. A new
+4 -2
View File
@@ -112,8 +112,10 @@ Telegram держит **единого бота**: все игроки поль
запрещено, только пока у аккаунтов есть общая незавершённая игра.
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram; добавление VK в вебе пока не предлагается, а внутри Mini App платформа-хозяин
уже привязана. Привязанного провайдера можно **отвязать** — кроме последнего
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
обратно); внутри Mini App платформа-хозяин уже привязана. Привязка провайдера, уже
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
email. Привязанного провайдера можно **отвязать** — кроме последнего
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
**Email не отвязывают — его меняют**: игрок вводит новый адрес, подтверждает код,
отправленный на него, и аккаунт атомарно переключается на новый адрес, освобождая
+4
View File
@@ -25,12 +25,16 @@ ARG VITE_TELEGRAM_BOT_ID=
ARG VITE_TELEGRAM_LINK=
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME=
ARG VITE_VK_APP_LINK=
ARG VITE_VK_APP_ID=
ARG VITE_VK_ID_REDIRECT_URL=
ARG VITE_GATEWAY_URL=
ARG VITE_APP_VERSION=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
VITE_VK_APP_LINK=$VITE_VK_APP_LINK \
VITE_VK_APP_ID=$VITE_VK_APP_ID \
VITE_VK_ID_REDIRECT_URL=$VITE_VK_ID_REDIRECT_URL \
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
VITE_APP_VERSION=$VITE_APP_VERSION
+11 -3
View File
@@ -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
(`auth.vk` is unregistered).
`link.vk.confirm`/`merge` link a VK identity from a **browser** (not a Mini App) via **VK ID web
login** (`internal/vkid`): the SPA runs the VK ID raw OAuth 2.1 flow (PKCE, no `@vkid/sdk`) and
the gateway completes the **confidential code exchange** at `id.vk.com` under a SEPARATE VK "Web"
app's protected key — the only op here that calls VK (the Mini App path above is offline). All
three `GATEWAY_VK_ID_*` unset leaves the ops unregistered.
The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`,
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
@@ -72,9 +78,10 @@ refetch). The social/account/history ops —
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
`stats.get`, `game.gcg`, and the `notify` live event — go through the identical
transcode pattern (`transcode_social.go`). Account linking & merge
`link.email.request/confirm/merge` and `link.telegram.confirm/merge`
(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the
validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
`link.email.request/confirm/merge`, `link.telegram.confirm/merge` and
`link.vk.confirm/merge` (`transcode_link.go`); the telegram ops validate the **Login
Widget** payload via the validator (`ValidateLoginWidget`), the vk ops complete the VK ID
web code exchange via `internal/vkid`, and both forward the trusted `external_id`. These
**superseded** the former `email.bind.*` ops, which were removed.
## Configuration
@@ -89,6 +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_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
| `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) |
| `GATEWAY_VK_ID_APP_ID` / `GATEWAY_VK_ID_CLIENT_SECRET` / `GATEWAY_VK_ID_REDIRECT_URL` | unset | VK ID "Web" app credentials for VK web-login linking (`link.vk.*`): the server-side confidential OAuth 2.1 code exchange. A separate VK app from `GATEWAY_VK_APP_SECRET`; all three required to enable the ops |
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
+10 -1
View File
@@ -33,6 +33,7 @@ import (
"scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode"
"scrabble/gateway/internal/vkid"
"scrabble/pkg/mtls"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
telegramv1 "scrabble/pkg/proto/telegram/v1"
@@ -190,7 +191,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
}
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret))
// VK ID web login (browser VK-identity linking) is optional: build the confidential
// code-exchanger only when fully configured, else leave the interface nil so the
// link.vk.* ops stay unregistered.
var vkidExchanger transcode.VKIDExchanger
if cfg.VKID.Enabled() {
vkidExchanger = vkid.New(cfg.VKID.AppID, cfg.VKID.ClientSecret, cfg.VKID.RedirectURI)
logger.Info("vk id web login enabled")
}
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger))
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry,
Sessions: sessions,
@@ -335,6 +335,24 @@ func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID strin
return out, err
}
// LinkVK attaches a gateway-validated VK identity to the caller or reports a required
// merge. externalID is the trusted vk user id resolved from the VK ID code exchange.
func (c *Client) LinkVK(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
var out LinkResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/vk", userID, "",
map[string]string{"external_id": externalID}, &out)
return out, err
}
// LinkVKMerge merges the account owning a gateway-validated VK identity into the
// caller's.
func (c *Client) LinkVKMerge(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
var out LinkResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/vk/merge", userID, "",
map[string]string{"external_id": externalID}, &out)
return out, err
}
// ChangeEmailRequest asks the backend to mail a confirm-code to a new address for an
// authenticated email change.
func (c *Client) ChangeEmailRequest(ctx context.Context, userID, email string) error {
+26
View File
@@ -36,6 +36,11 @@ type Config struct {
// VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
VKAppSecret string
// VKID configures the VK ID web login used to link a VK identity from a browser
// (the confidential OAuth 2.1 code exchange against id.vk.com). It belongs to a
// separate VK "Web" app from VKAppSecret's Mini App, so its credentials are distinct.
// Any field empty disables the VK web-link ops (link.vk.*).
VKID VKIDConfig
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
BotLink BotLinkConfig
@@ -161,6 +166,22 @@ func DefaultAbuse() AbuseConfig {
}
}
// VKIDConfig holds the VK ID web-login credentials for the confidential
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
// with the app and the one the frontend uses. All three are required to enable the flow.
type VKIDConfig struct {
AppID string
ClientSecret string
RedirectURI string
}
// Enabled reports whether VK ID web login is fully configured. When false the gateway
// leaves the VK web-link ops (link.vk.*) unregistered.
func (c VKIDConfig) Enabled() bool {
return c.AppID != "" && c.ClientSecret != "" && c.RedirectURI != ""
}
// Load reads the configuration from the environment, applies defaults, and
// validates the result.
func Load() (Config, error) {
@@ -174,6 +195,11 @@ func Load() (Config, error) {
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
VKID: VKIDConfig{
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
RedirectURI: os.Getenv("GATEWAY_VK_ID_REDIRECT_URL"),
},
SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(),
Abuse: DefaultAbuse(),
+14
View File
@@ -32,6 +32,8 @@ type serverMetrics struct {
localColdStart metric.Int64Counter
localDictLoad metric.Int64Counter
localPreview metric.Int64Counter
// Clients turned away by the unsupported-engine boot screen (see unsupportedEngineHandler).
unsupportedEngine metric.Int64Counter
}
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
@@ -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."),
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
unsupportedEngine: counterOf(meter, "unsupported_engine_total",
"Clients that hit the unsupported-engine boot screen (the app cannot run), by reason (no_bigint, no_proxy, boot_error, other) and Chromium major — a deduped beacon from the index.html boot guard; the full user agent is logged, not labelled."),
}
gauge, err := meter.Int64ObservableGauge("active_users",
@@ -108,6 +112,16 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
}
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
func (m *serverMetrics) recordUnsupportedEngine(ctx context.Context, reason, chromium string) {
m.unsupportedEngine.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
attribute.String("chromium", chromium),
))
}
// localEvalReport is the client-reported local move-preview telemetry batch — deltas since
// the client's previous report. It backs the adoption dashboard: app cold starts vs cached
// dictionaries vs on-device previews.
@@ -128,3 +128,70 @@ func TestBannedMetric(t *testing.T) {
t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts)
}
}
// TestUnsupportedEngineMetric records unsupported-engine beacons through a manual reader and asserts
// unsupported_engine_total splits by reason and Chromium major.
func TestUnsupportedEngineMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
m.recordUnsupportedEngine(ctx, "boot_error", "74")
var rm metricdata.ResourceMetrics
if err := reader.Collect(ctx, &rm); err != nil {
t.Fatalf("collect: %v", err)
}
type key struct{ reason, chromium string }
counts := map[key]int64{}
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != "unsupported_engine_total" {
continue
}
sum, ok := md.Data.(metricdata.Sum[int64])
if !ok {
t.Fatalf("unsupported_engine_total is not an int64 sum")
}
for _, dp := range sum.DataPoints {
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
counts[key{reason.AsString(), chromium.AsString()}] += dp.Value
}
}
}
if got := counts[key{"no_bigint", "66"}]; got != 2 {
t.Errorf("unsupported no_bigint/66 = %d, want 2", got)
}
if got := counts[key{"boot_error", "74"}]; got != 1 {
t.Errorf("unsupported boot_error/74 = %d, want 1", got)
}
}
// TestNormalizeUnsupported checks that a beacon's reason and Chromium are reduced to bounded label
// values, so a spoofed beacon cannot explode the metric cardinality.
func TestNormalizeUnsupported(t *testing.T) {
cases := []struct {
reason, chromium string
wantReason, wantChromium string
}{
{"no_bigint", "66", "no_bigint", "66"},
{"no_proxy", " 74 ", "no_proxy", "74"},
{"boot_error", "105", "boot_error", "105"},
{"garbage", "66", "other", "66"},
{"", "", "other", "other"},
{"no_bigint", "not-a-number", "no_bigint", "other"},
{"no_bigint", "9999", "no_bigint", "other"},
{"no_bigint", "0", "no_bigint", "other"},
{"no_bigint", "-5", "no_bigint", "other"},
}
for _, c := range cases {
gotR, gotC := normalizeUnsupported(c.reason, c.chromium)
if gotR != c.wantReason || gotC != c.wantChromium {
t.Errorf("normalizeUnsupported(%q,%q) = %q,%q; want %q,%q", c.reason, c.chromium, gotR, gotC, c.wantReason, c.wantChromium)
}
}
}
+74
View File
@@ -15,6 +15,7 @@ import (
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
@@ -197,6 +198,9 @@ func (s *Server) HTTPHandler() http.Handler {
mux.Handle("/dl/", s.exportDownloadHandler())
// The client posts its local-move-preview adoption telemetry here (session-gated).
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
// screen (the app cannot run). Unauthenticated — the client never booted — but rate-limited.
mux.Handle("/telemetry/unsupported", s.unsupportedEngineHandler())
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
@@ -582,6 +586,76 @@ func clampReport(r *localEvalReport) {
clamp(&r.PreviewNetwork)
}
// unsupportedEngineBeacon is the small fire-and-forget report the index.html boot guard sends when
// it turns a client away on the unsupported-engine screen (no BigInt/Proxy, or an uncaught boot
// error). It is deduped client-side (one per device / app version / reason).
type unsupportedEngineBeacon struct {
Reason string `json:"reason"`
Chromium string `json:"chromium"`
Version string `json:"version"`
UA string `json:"ua"`
}
// unsupportedEngineHandler folds one unsupported-engine beacon into the edge counter. It is
// unauthenticated (the client never booted, so it carries no session) but per-IP rate-limited with
// the public limiter and body-capped. reason and the Chromium major are reduced to bounded label
// sets (normalizeUnsupported) so a spoofed beacon cannot inflate the metric cardinality; the full
// user agent is logged, not labelled. Only POST; the reply is always 204.
func (s *Server) unsupportedEngineHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "unsupported")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
var b unsupportedEngineBeacon
if err := json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&b); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
reason, chromium := normalizeUnsupported(b.Reason, b.Chromium)
s.metrics.recordUnsupportedEngine(r.Context(), reason, chromium)
s.log.Info("unsupported engine",
zap.String("reason", reason),
zap.String("chromium", chromium),
zap.String("app_version", truncate(b.Version, 40)),
zap.String("user_agent", truncate(b.UA, 400)),
)
w.WriteHeader(http.StatusNoContent)
})
}
// normalizeUnsupported reduces a beacon's reason and Chromium fields to bounded label values, so a
// spoofed beacon cannot explode the metric cardinality: reason is allow-listed, and Chromium is
// parsed as a major version kept only within a plausible range, otherwise "other".
func normalizeUnsupported(reason, chromium string) (string, string) {
switch reason {
case "no_bigint", "no_proxy", "boot_error":
// a recognised reason — keep as-is
default:
reason = "other"
}
major := "other"
if n, err := strconv.Atoi(strings.TrimSpace(chromium)); err == nil && n >= 1 && n <= 199 {
major = strconv.Itoa(n)
}
return reason, major
}
// truncate bounds a logged, client-supplied string to n bytes (a spoofed beacon field is not
// trusted to be small).
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// resolve extracts and resolves the Authorization bearer token to an account id
// and its guest flag, returning a Connect Unauthenticated error when it is missing
// or unknown.
@@ -0,0 +1,81 @@
package connectsrv_test
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/connectsrv"
"scrabble/gateway/internal/ratelimit"
)
// TestUnsupportedEngineHandler drives the /telemetry/unsupported beacon route end to end: a POST
// increments unsupported_engine_total with the normalised labels and replies 204; a GET is 405.
func TestUnsupportedEngineHandler(t *testing.T) {
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
edge := connectsrv.NewServer(connectsrv.Deps{
Limiter: ratelimit.New(),
RateLimit: config.DefaultRateLimit(),
Meter: meter,
})
srv := httptest.NewServer(edge.HTTPHandler())
defer srv.Close()
url := srv.URL + "/telemetry/unsupported"
// A GET is rejected.
getResp, err := http.Get(url)
if err != nil {
t.Fatalf("get: %v", err)
}
getResp.Body.Close()
if getResp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET status = %d, want 405", getResp.StatusCode)
}
// A POST is accepted (204) and folded into the counter with normalised labels.
body := `{"reason":"no_bigint","chromium":"66","version":"v1.2.3","ua":"Mozilla/5.0 ... Chrome/66"}`
resp, err := http.Post(url, "application/json", bytes.NewBufferString(body))
if err != nil {
t.Fatalf("post: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("POST status = %d, want 204", resp.StatusCode)
}
var rm metricdata.ResourceMetrics
if err := reader.Collect(context.Background(), &rm); err != nil {
t.Fatalf("collect: %v", err)
}
var total int64
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != "unsupported_engine_total" {
continue
}
sum, ok := md.Data.(metricdata.Sum[int64])
if !ok {
t.Fatalf("unsupported_engine_total is not an int64 sum")
}
for _, dp := range sum.DataPoints {
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
if reason.AsString() != "no_bigint" || chromium.AsString() != "66" {
t.Errorf("labels = %s/%s, want no_bigint/66", reason.AsString(), chromium.AsString())
}
total += dp.Value
}
}
}
if total != 1 {
t.Errorf("unsupported_engine_total = %d, want 1", total)
}
}
+13
View File
@@ -14,6 +14,7 @@ import (
"scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/connector"
"scrabble/gateway/internal/vkauth"
"scrabble/gateway/internal/vkid"
fb "scrabble/pkg/fbs/scrabblefb"
)
@@ -152,6 +153,15 @@ func WithVKAuth(secret string) Option {
}
}
// WithVKLink registers the VK web-link ops (link.vk.confirm/merge), which complete a
// browser VK ID authorization via the server-side code exchange. A nil exchanger leaves
// them unregistered, so the ops are unknown wherever VK ID web login is not configured.
func WithVKLink(ex VKIDExchanger) Option {
return func(r *Registry, backend *backendclient.Client) {
registerVKLinkOps(r, backend, ex)
}
}
// Lookup returns the operation for messageType, and whether it is registered.
func (r *Registry) Lookup(messageType string) (Op, bool) {
op, ok := r.ops[messageType]
@@ -172,6 +182,9 @@ func DomainCode(err error) (string, bool) {
if errors.Is(err, vkauth.ErrInvalid) {
return "invalid_vk_params", true
}
if errors.Is(err, vkid.ErrInvalid) {
return "invalid_vk_id", true
}
if errors.Is(err, connector.ErrInvalidLoginWidget) {
return "invalid_login_widget", true
}
@@ -4,6 +4,7 @@ import (
"context"
"scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/vkid"
fb "scrabble/pkg/fbs/scrabblefb"
)
@@ -18,6 +19,8 @@ const (
MsgLinkEmailMerge = "link.email.merge"
MsgLinkTelegram = "link.telegram.confirm"
MsgLinkTelegramMerge = "link.telegram.merge"
MsgLinkVK = "link.vk.confirm"
MsgLinkVKMerge = "link.vk.merge"
MsgLinkUnlink = "link.unlink"
MsgEmailChangeRequest = "link.email.change.request"
MsgEmailChangeConfirm = "link.email.change.confirm"
@@ -154,3 +157,44 @@ func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, me
return encodeLinkResult(res), nil
}
}
// VKIDExchanger completes a VK ID web authorization-code exchange, returning the
// launching user's trusted VK identity. It is satisfied by *vkid.Exchanger and lets the
// VK web-link ops resolve a browser VK login, which — unlike the Mini App path — has no
// offline launch signature to verify.
type VKIDExchanger interface {
Exchange(ctx context.Context, code, deviceID, codeVerifier string) (vkid.Identity, error)
}
// registerVKLinkOps adds the VK web-link ops when a VK ID exchanger is configured; a nil
// exchanger leaves them unregistered (VK ID web login not configured).
func registerVKLinkOps(r *Registry, backend *backendclient.Client, ex VKIDExchanger) {
if ex == nil {
return
}
r.ops[MsgLinkVK] = Op{Handler: linkVKHandler(backend, ex, false), Auth: true}
r.ops[MsgLinkVKMerge] = Op{Handler: linkVKHandler(backend, ex, true), Auth: true}
}
// linkVKHandler completes the VK ID code exchange (server-side, under the app's
// protected key) and then calls the backend's link or merge endpoint with the trusted
// VK external id.
func linkVKHandler(backend *backendclient.Client, ex VKIDExchanger, merge bool) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkVKRequest(req.Payload, 0)
user, err := ex.Exchange(ctx, string(in.Code()), string(in.DeviceId()), string(in.CodeVerifier()))
if err != nil {
return nil, err
}
var res backendclient.LinkResultResp
if merge {
res, err = backend.LinkVKMerge(ctx, req.UserID, user.ExternalID)
} else {
res, err = backend.LinkVK(ctx, req.UserID, user.ExternalID)
}
if err != nil {
return nil, err
}
return encodeLinkResult(res), nil
}
}
@@ -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")
}
}
+163
View File
@@ -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 ""
}
+131
View File
@@ -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)
}
}
+11
View File
@@ -501,6 +501,17 @@ table LinkTelegramRequest {
data:string;
}
// LinkVKRequest carries a VK ID web authorization for attaching a VK identity to the
// current account. The fields are the PKCE code-exchange inputs the frontend obtains
// from the VK ID SDK (One Tap): the authorization code, the device id issued with it,
// and the PKCE code verifier. The gateway completes the confidential code exchange
// server-side (under the app's protected key) to obtain the trusted vk user id.
table LinkVKRequest {
code:string;
device_id:string;
code_verifier:string;
}
// LinkUnlinkRequest detaches a platform identity (kind = "telegram" | "vk") from the
// caller's account; email is never unlinked (it is changed).
table LinkUnlinkRequest {
+82
View File
@@ -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()
}
+38
View File
@@ -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
View File
@@ -2,6 +2,240 @@
<html lang="ru">
<head>
<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
out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can
reach this tag). -->
+1
View File
@@ -27,6 +27,7 @@
"@playwright/test": "^1.49.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.10.0",
"core-js-bundle": "^3.49.0",
"svelte": "^5.15.0",
"svelte-check": "^4.1.0",
"typescript": "^5.7.0",
+8
View File
@@ -36,6 +36,9 @@ importers:
'@types/node':
specifier: ^22.10.0
version: 22.19.19
core-js-bundle:
specifier: ^3.49.0
version: 3.49.0
svelte:
specifier: ^5.15.0
version: 5.56.0
@@ -508,6 +511,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
core-js-bundle@3.49.0:
resolution: {integrity: sha512-WXc7oOsePN3aKFOJVG5zQdi+h/Jm2W0WIPYvRc4IG3vkNcbC2w6LlSzTmnhOl6N1xmOJEzCSNieX3mwF+3zBGw==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1132,6 +1138,8 @@ snapshots:
clsx@2.1.1: {}
core-js-bundle@3.49.0: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
+4
View File
@@ -1,4 +1,8 @@
# pnpm 11 records build-script approval here. esbuild's postinstall materialises
# 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:
core-js-bundle: false
esbuild: true
+8 -1
View File
@@ -9,6 +9,7 @@
import StaleInviteModal from './components/StaleInviteModal.svelte';
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
import Coachmark from './components/Coachmark.svelte';
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
import DebugPanel from './components/DebugPanel.svelte';
import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte';
@@ -25,7 +26,12 @@
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
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
@@ -128,6 +134,7 @@
<StaleInviteModal />
<WelcomeRedeemModal />
<Coachmark />
<MaintenanceOverlay />
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
<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>
+11
View File
@@ -479,6 +479,11 @@
position: absolute;
top: 5%;
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-weight: 700;
line-height: 1;
@@ -487,12 +492,14 @@
position: absolute;
right: 5%;
bottom: 3%;
font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.4cqw;
font-weight: 600;
}
/* 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. */
.blankmark {
font-size: calc(2.8vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.8cqw;
bottom: 0;
}
@@ -501,6 +508,7 @@
inset: 0;
display: grid;
place-items: center;
font-size: calc(3.6vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 3.6cqw;
opacity: 0.7;
}
@@ -509,6 +517,7 @@
inset: 0;
display: grid;
place-items: center;
font-size: calc(2.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.7cqw;
font-weight: 600;
opacity: 0.9;
@@ -526,10 +535,12 @@
padding: 0 1px;
}
.bt {
font-size: calc(1.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 1.7cqw;
font-weight: 600;
}
.bb {
font-size: calc(1.9vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 1.9cqw;
font-weight: 700;
white-space: nowrap;
+1
View File
@@ -54,6 +54,7 @@ export { LinkEmailRequest } from './scrabblefb/link-email-request.js';
export { LinkResult } from './scrabblefb/link-result.js';
export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js';
export { LinkUnlinkRequest } from './scrabblefb/link-unlink-request.js';
export { LinkVKRequest } from './scrabblefb/link-vkrequest.js';
export { MatchFoundEvent } from './scrabblefb/match-found-event.js';
export { MatchResult } from './scrabblefb/match-result.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);
}
}
+17 -1
View File
@@ -34,6 +34,7 @@ import {
telegramCloudSet,
} from './telegram';
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 { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
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"
* screen and all push/poll is stopped. Reopening the app just creates a fresh account. */
accountDeleted: boolean;
/** A VK ID web-link authorization callback captured on boot (see lib/vkid): the browser
* returned from VK with an auth code. Profile consumes it on mount to finish the link or
* merge (a full-page redirect loses the route, so boot routes here). Null on a normal load. */
vkLinkPending: VKLinkCallback | null;
toast: Toast | null;
lastEvent: PushEvent | null;
theme: ThemePref;
@@ -145,6 +150,7 @@ export const app = $state<{
profile: null,
blocked: null,
accountDeleted: false,
vkLinkPending: null,
toast: null,
lastEvent: null,
theme: 'auto',
@@ -778,9 +784,19 @@ export async function bootstrap(): Promise<void> {
}
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) {
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') {
navigate('/login');
}
+4
View File
@@ -177,6 +177,10 @@ export interface GatewayClient {
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
linkTelegram(data: string): Promise<LinkResult>;
linkTelegramMerge(data: string): Promise<LinkResult>;
/** Link a VK identity from the web: the args are the PKCE outputs of the VK ID
* authorization callback, exchanged for the trusted vk id on the gateway. */
linkVK(code: string, deviceId: string, codeVerifier: string): Promise<LinkResult>;
linkVKMerge(code: string, deviceId: string, codeVerifier: string): Promise<LinkResult>;
/** Detach a platform identity (kind 'telegram' | 'vk') from the account; email is never
* unlinked. Returns the refreshed link result (status 'unlinked'). */
linkUnlink(kind: string): Promise<LinkResult>;
+8
View File
@@ -31,6 +31,7 @@ import {
decodeDeleteRequestResult,
encodeGuestLogin,
encodeLinkUnlink,
encodeLinkVK,
encodeStateRequest,
encodeSubmitPlay,
encodeTarget,
@@ -466,6 +467,13 @@ describe('codec', () => {
expect(r.kind()).toBe('telegram');
});
it('encodes a VK link request carrying the PKCE code-exchange inputs', () => {
const r = fb.LinkVKRequest.getRootAsLinkVKRequest(new ByteBuffer(encodeLinkVK('the-code', 'dev-1', 'verifier-1')));
expect(r.code()).toBe('the-code');
expect(r.deviceId()).toBe('dev-1');
expect(r.codeVerifier()).toBe('verifier-1');
});
it('round-trips the account-delete confirm + request-result wire', () => {
const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm(
new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')),
+12
View File
@@ -739,6 +739,18 @@ export function encodeLinkUnlink(kind: string): Uint8Array {
return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b));
}
export function encodeLinkVK(code: string, deviceId: string, codeVerifier: string): Uint8Array {
const b = new Builder(256);
const c = b.createString(code);
const d = b.createString(deviceId);
const v = b.createString(codeVerifier);
fb.LinkVKRequest.startLinkVKRequest(b);
fb.LinkVKRequest.addCode(b, c);
fb.LinkVKRequest.addDeviceId(b, d);
fb.LinkVKRequest.addCodeVerifier(b, v);
return finish(b, fb.LinkVKRequest.endLinkVKRequest(b));
}
export function encodeAccountDeleteConfirm(code: string, phrase: string): Uint8Array {
const b = new Builder(64);
const c = b.createString(code);
+10
View File
@@ -6,6 +6,7 @@ import type { GatewayClient } from './client';
import { MockGateway } from './mock/client';
import { createTransport } from './transport';
import { reportOffline, reportOnline } from './connection.svelte';
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
const isMock = import.meta.env.MODE === 'mock';
@@ -20,6 +21,15 @@ if (isMock && typeof window !== 'undefined') {
offline: reportOffline,
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
// attaches a robot on a timer).
(
+4
View File
@@ -7,6 +7,9 @@ export const en = {
'app.brand': 'Erudit',
'dict.loading': 'Loading…',
'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.permanent': 'Your account is blocked.',
@@ -173,6 +176,7 @@ export const en = {
'profile.guestLocked': 'Sign in with email to manage your profile.',
'profile.linkAccount': 'Link an account',
'profile.linkTelegram': 'Link Telegram',
'profile.linkVK': 'Link VK',
'profile.linked': 'Account linked.',
'profile.merged': 'Accounts merged.',
'profile.mergeTitle': 'Merge accounts?',
+4
View File
@@ -8,6 +8,9 @@ export const ru: Record<MessageKey, string> = {
'app.brand': 'Эрудит',
'dict.loading': 'Загрузка…',
'connection.connecting': 'Подключение…',
'maintenance.title': 'Технические работы',
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
'maintenance.retry': 'Повторить',
'blocked.title': 'Учётная запись заблокирована',
'blocked.permanent': 'Ваша учётная запись заблокирована.',
@@ -173,6 +176,7 @@ export const ru: Record<MessageKey, string> = {
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
'profile.linkAccount': 'Привязать аккаунт',
'profile.linkTelegram': 'Привязать Telegram',
'profile.linkVK': 'Привязать VK',
'profile.linked': 'Аккаунт привязан.',
'profile.merged': 'Аккаунты объединены.',
'profile.mergeTitle': 'Объединить аккаунты?',
+99
View File
@@ -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();
},
);
}
+44
View File
@@ -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 3s120s 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);
});
});
+37
View File
@@ -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'));
}
+10
View File
@@ -674,6 +674,16 @@ export class MockGateway implements GatewayClient {
this.profile.telegramLinked = true;
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> {
if (kind === 'telegram') this.profile.telegramLinked = false;
if (kind === 'vk') this.profile.vkLinked = false;
+30 -3
View File
@@ -12,6 +12,8 @@ import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec';
import { browserOffset } from './profileValidation';
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';
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
// (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');
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
// 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 });
} catch (e) {
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);
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
reportOffline();
@@ -53,6 +67,9 @@ export function createTransport(baseUrl: string): GatewayClient {
throw err;
}
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);
return res.payload;
}
@@ -268,6 +285,12 @@ export function createTransport(baseUrl: string): GatewayClient {
async linkTelegramMerge(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) {
return codec.decodeLinkResult(await exec('link.unlink', codec.encodeLinkUnlink(kind)));
},
@@ -304,7 +327,11 @@ export function createTransport(baseUrl: string): GatewayClient {
if (pe) onEvent(pe);
}
} 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();
+122
View File
@@ -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 };
}
+60 -3
View File
@@ -14,6 +14,7 @@
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
import { startVKLink, vkWebLinkAvailable } from '../lib/vkid';
import { t } from '../lib/i18n/index.svelte';
import {
awayDurationOk,
@@ -45,7 +46,7 @@
let emailSent = $state(false);
// 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.
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 = '';
// The change-email sub-form is open (a new address is being entered/confirmed).
let changingEmail = $state(false);
@@ -56,6 +57,7 @@
let deleteCode = $state('');
let deletePhrase = $state('');
const telegramLinkable = loginWidgetAvailable();
const vkLinkable = vkWebLinkAvailable();
function defaultTz(): string {
const b = browserOffset();
@@ -82,7 +84,12 @@
notificationsInAppOnly = p.notificationsInAppOnly;
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 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() {
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 {
const r =
pendingMerge.kind === 'email'
@@ -344,7 +391,7 @@
<!-- 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
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
offered only when another identity remains (the backend also refuses the last one). -->
<section class="accounts">
@@ -413,6 +460,10 @@
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
{/if}
</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}
</section>
@@ -644,6 +695,12 @@
display: flex;
gap: 8px;
}
/* When a button row sits below its own field (the change-email and delete dialogs),
separate them and keep the actions right-aligned. */
.addrow.end {
margin-top: 14px;
justify-content: flex-end;
}
.addrow input {
flex: 1;
min-width: 0;
+47 -3
View File
@@ -1,7 +1,42 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import { defineConfig, type Plugin } from 'vite';
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
// 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`
@@ -19,7 +54,9 @@ export default defineConfig(({ mode }) => ({
// so a missing build-arg never breaks the build.
__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: {
port: 5173,
proxy:
@@ -33,7 +70,14 @@ export default defineConfig(({ mode }) => ({
},
},
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`
// carries full `sourcesContent` — the entire TypeScript/Svelte source — and the
// gateway/landing images serve `dist/` verbatim, so production maps would expose