Compare commits

..

218 Commits

Author SHA1 Message Date
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer 3a85f64726 Merge pull request 'fix(metrics): second-scale buckets for edge_request_duration' (#186) from fix/edge-latency-histogram-buckets into development
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 1s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 14s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / deploy (push) Successful in 1m46s
CI / unit (pull_request) Successful in 10s
CI / deploy (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
2026-07-05 20:55:31 +00:00
Ilia Denisov 00bb66ba0f fix(metrics): second-scale buckets for edge_request_duration
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) Has been skipped
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
The edge request-latency histogram records seconds but used the OTel SDK's
default millisecond-calibrated bucket boundaries (first boundary 5), so every
sub-5s request fell into one bucket and histogram_quantile(0.99) interpolated
to ~4.95s for every message type — regardless of the real (millisecond)
latency. That tripped the >1s "Gateway request latency p99 high" alert on
essentially every request, flapping fire/resolve on each app open (seen on the
test and prod contours).

Set explicit second-scale bucket boundaries (0.005 … 10s) straddling the 1s SLO
so the p99 reflects real latency and the alert fires only on genuine slowness.
Regression test asserts the histogram carries a sub-second boundary.
2026-07-05 22:49:04 +02:00
developer a2c79c149a Merge pull request 'feat(email): PWA login sends code only; drop admin link from alert emails' (#185) from feature/pwa-login-code-only into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 18s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m50s
2026-07-05 20:26:04 +00:00
Ilia Denisov 061366da5a feat(email): PWA login sends code only; drop admin link from alert emails
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 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Two email changes, per the owner:

1. Code-only login from an installed PWA. A login requested while running as a
   standalone PWA now omits the one-tap confirm link from the email — the link
   would open in a separate browser whose minted session cannot reach the PWA,
   stranding the login. The code is typed in the same window instead. The client
   sends a `pwa` flag (isStandalone) on the email-code request (a new FBS field,
   threaded through the gateway); the backend omits the deeplink when it is set,
   reusing the existing deletion-code link-omission path. Non-PWA browser logins
   keep the one-tap link. No polling, no migration.

2. Security: the operator alert digest no longer embeds the admin-console (/_gm)
   URL. An admin link must never travel in an email, where a mail provider could
   cache or index it; the operator opens the console directly.

Tests: an inttest asserting the PWA login email omits the link (and a browser one
keeps it); a codec round-trip of the new pwa field; the alert-digest test flipped
to guard the admin link is absent. Docs: ARCHITECTURE + FUNCTIONAL (+ru).
2026-07-05 22:13:21 +02:00
developer d19609f87d Merge pull request 'feat(pwa): installable web app + landing web entry' (#184) from feature/pwa-install-cta into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m40s
2026-07-05 19:36:37 +00:00
Ilia Denisov 51147a1429 feat(pwa): installable web app + landing web entry
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 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
Make the web SPA an installable PWA and surface it to users:

- manifest.webmanifest + an install-only service worker + 192/512/maskable
  icons (ui/public); PWA head tags in index.html; the .webmanifest MIME type
  registered in the gateway (the distroless image has no /etc/mime.types).
- Platform-adaptive install CTA (components/InstallApp.svelte + lib/pwa):
  one-tap on Chromium, manual Add-to-Home-Screen instructions on iOS Safari,
  hidden elsewhere / once installed / inside a Mini App. Shown under the
  logged-out login card and at the bottom of Settings.
- The landing gains a third entry linking /app/ (the brand tile), with a
  caption under all three (Telegram / VK / Веб-версия).

The service worker is navigation-only (network-first, cached-shell fallback);
hashed assets and the Connect stream are untouched. It exists to satisfy
Chromium's installability requirement and is the single growth point for a
future opt-in offline mode.

Tests: pwa.ts unit tests; a webui probe (manifest/sw.js content-type + the
SPA-fallback-to-HTML trap); e2e for the one-tap CTA, the iOS instructions
modal and the landing web entry. Docs: ARCHITECTURE §13, FUNCTIONAL (+ru),
gateway README.
2026-07-05 21:11:41 +02:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer d61ba68e9b Merge pull request 'feat(banner): per-campaign colour overrides and urgent alerts' (#182) from feature/banner-colors-urgent into development
CI / integration (push) Successful in 15s
CI / deploy (push) Successful in 1m57s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / conformance (pull_request) Successful in 8s
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 / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / ui (pull_request) Successful in 1m8s
2026-07-05 13:57:32 +00:00
Ilia Denisov 6db9178449 feat(banner): per-campaign colour overrides and urgent alerts
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 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Non-default campaigns gain an optional colour override (background / text /
link) in two sets — one for every theme, one for the dark theme only — and an
"urgent" flag.

- Colours ride profile.get as six trailing FlatBuffers strings on
  BannerCampaign (backward-compatible). The client resolves the cascade
  (dark <- dark ?? all, light <- all) per rendered theme and derives the strip
  border from the background in JS (no CSS color-mix, for the old Android
  WebView floor); AdBanner applies them as inline vars scoped to the strip.
- Urgent is resolved entirely server-side: while any enabled, in-window urgent
  campaign exists, computeActiveSet returns only the urgent campaigns and
  bannerFor skips the eligibility gate — so a system notice reaches every viewer
  (paid / hint-holding / no_banner included) and preempts the ordinary feed. No
  wire field; it appears on each viewer's next profile.get.
- Admin console (/_gm/banners): native colour pickers + a live light/dark
  preview of the strip, and an urgent toggle. The default campaign stays plain,
  enforced by the service and a DB CHECK.

Migration 00009 is additive (nullable colour columns + a bool default +
all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe.

Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent
preempt + colour validation), codec + resolver unit, gateway transcode, and
integration (colour round-trip + urgent bypass against real Postgres).
2026-07-05 15:36:35 +02:00
developer ac383880b7 Merge pull request 'fix(ui): hold info toast ~1s before it rises and fades' (#181) from feature/toast-hold-before-fade 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 1m7s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
2026-07-05 12:24:08 +00:00
developer 16349f5ad9 Merge pull request 'fix(ui): poll for out-of-band email confirmation' (#180) from feature/email-confirm-poll-fallback into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m45s
2026-07-05 12:20:38 +00:00
developer 101a3f118c Merge pull request 'feat(ui): hide current-host sign-in row in profile' (#179) from feature/hide-current-host-signin-row 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 1m4s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m33s
2026-07-05 12:16:22 +00:00
Ilia Denisov 400b6ac2a5 fix(ui): hold info toast ~1s before it rises and fades
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 1m8s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The info toast began drifting up and fading the instant it finished
appearing (the CSS keyframes went straight from the 12% appeared-stop to
the 100% risen-and-faded stop), so a glanced message was already leaving.

Insert a ~1s hold at full opacity/rest before the rise-and-fade: extend the
animation 2s → 3s with keyframe stops at 8% (appeared, ~240ms) and 41% (end
of the ~1s hold), keeping the original rise-and-fade pace for the tail. The
reduced-motion variant gets the same appear/hold/fade timing (fade only, no
travel). The showToast dismissal timer is bumped 2000 → 3000ms to stay in
lockstep with the animation (the error toast's 4s dwell is unchanged).
2026-07-05 14:10:34 +02:00
Ilia Denisov fb7490f1df fix(ui): poll for out-of-band email confirmation
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 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
An email link/code can be confirmed out of band: the recipient taps the
one-tap link in the email, which confirms in another browser/session. The
backend already publishes a `notify` `profile` re-fetch signal for this
(handlers_auth.go handleEmailConfirmLink), and the client re-fetches on it.

But the live stream is single-shot with no replay: a Mini App backgrounded
while the user is in their mail app tapping the link drops the stream and
misses the event (the gateway hub has no subscriber to deliver to), and the
reconnect on foreground does not re-sync — so the open code form stayed
until a manual reload. On Telegram Desktop the app is never backgrounded,
so the push works and there was no bug.

Add a client-side fallback: while an add-email confirmation is pending
(a code was sent, no email yet), poll `profile.get` on a 4s interval and on
foreground regain until the address lands; the effect stops as soon as the
email appears. The live push still updates instantly when foregrounded —
this only covers the backgrounded-miss gap.

Tests: a mock e2e attaches the email WITHOUT emitting a live event (new
window.__mock.clearEmail / confirmEmailOutOfBand seams), so it exercises the
poll, not the push, and asserts the code form collapses into the email row.
Docs: ARCHITECTURE.md §10 notes the single-shot gap + the poll fallback.
2026-07-05 13:58:39 +02:00
Ilia Denisov c1805e5b7c feat(ui): hide current-host sign-in row in profile
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 1m40s
Inside a Telegram/VK Mini App the host provider is auto-linked. Once the
player also linked an email, `canUnlink` turned true and the "Unlink"
control appeared on the host platform's own row — letting them unlink the
very platform they are signed in through, which is meaningless.

Gate the Telegram row on `!insideTelegram()` and the VK row on
`!insideVK()`, reusing the runtime host detectors that already gate the
"link" buttons. Symmetric: inside TG only the TG row is hidden (the VK row
still shows, since VK is not the current host), and vice versa. The web and
native builds are unchanged (both detectors are false there); the backend
is untouched — this is a UI display gate, and `linkUnlink` still refuses to
remove the last identity.

Docs: FUNCTIONAL.md (+_ru mirror).
2026-07-05 13:10:40 +02:00
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
Ilia Denisov c2a8426b74 docs: account deletion = legal retention, not erasure
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 18s
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) Successful in 1m43s
FUNCTIONAL (+ru): the deletion user story — anonymised live surfaces ([Deleted]),
freed sign-in methods, a two-year-retained dossier, the code/phrase step-up, game
forfeit + all-robot drop, fresh account on reopen. ARCHITECTURE: the retention model
(retained_identities journal on every detach, tombstone + [Deleted] sentinel, drop
all-robot games, purpose=delete step-up, cold-load last-login, two-year TTL reaper).
2026-07-03 13:24:01 +02:00
Ilia Denisov 251c7af3f6 feat(admin): deleted-account dossier + operator delete-user action
The user-detail console gains a Deletion & retention panel: last login (time + IP),
the tombstone (deleted-at + retained real name), and the retention journal (the legal
dossier of detached credentials). A Delete-user action runs the same deletion
orchestration as the in-app flow (mirrors the email-erase pattern). Store readers
RetainedIdentities + DeletionInfo back the view; integration test covers them.
2026-07-03 13:22:33 +02:00
Ilia Denisov cabcd94d92 feat(profile): account-deletion flow + terminal deleted screen
Profile gains a Delete-account control (durable accounts) opening a step-up dialog: a
mailed code for an email account, or the typed DELETE phrase for a platform-only one.
On success the app swaps to a terminal AccountDeleted screen ('Учётная запись удалена')
with a Close that closes the host Mini App (telegramClose / vkClose; web = no close).
Wires deleteRequest/deleteConfirm through client/transport/mock/codec; ru/en i18n;
codec wire test + Chromium/WebKit e2e.
2026-07-03 13:18:37 +02:00
Ilia Denisov aa2290b7b4 feat(account): deletion orchestration + step-up + gateway edge
Step-up: email accounts confirm with a mailed code (purpose=delete, no deeplink —
ConfirmByToken refuses a delete token so a stray click can't delete); platform-only
accounts type a fixed phrase (anti-impulse). Endpoints /user/delete/{request,confirm};
the confirm orchestration resigns active games, drops all-robot games, tombstones +
anonymizes the account (freeing its creds), and revokes its sessions — the tombstone is
the point of no return, the rest best-effort. Gateway account.delete.{request,confirm}
ops + fbs AccountDeleteConfirm/AccountDeleteRequestResult + branded ru/en delete email.
Integration tests cover the step-up (code + no-email) and the orchestration pieces.
2026-07-03 13:10:34 +02:00
Ilia Denisov fcde7d3db6 feat(accountdelete): drop all-robot games + unspoofable [Deleted] label
Q2=B: DropAllRobotGames deletes the deletee's games with no human opponent (vs-AI or
auto-match-robot; children cascade), keeping games with any human seat (anonymized
instead). Q1=A: the anon label is the sentinel [Deleted] — the editable-name rule forbids
brackets, so no live player can impersonate a deleted account. Integration test covers
drop-vs-keep.
2026-07-03 13:02:14 +02:00
Ilia Denisov d889edfdb9 feat(account): retention TTL reaper (2-year legal hold)
Daily background reaper purges the deletion dossier past its two-year TTL: every
retained_identities row by detached_at (covering unlink/change on live accounts too),
and — for accounts tombstoned before the cutoff — the retained feedback thread plus the
dossier PII (deleted_display_name, last_login_ip). Chat is kept (a shared game artifact)
and the tombstone row stays. Started from main next to the guest reaper. Integration
test covers the cutoff boundary and the deleted-account feedback/PII purge.
2026-07-03 12:08:55 +02:00
Ilia Denisov 52e6378f40 feat(accountdelete): anonymize-and-tombstone deletion core
New accountdelete package: AnonymizeAndTombstone journals every credential to the
retention log (reason=delete) then removes them (freeing email/vk/tg for reuse),
snapshots the real display name into deleted_display_name and scrubs the live one to
'Удалённый игрок', sets deleted_at, anonymizes the account's game-seat snapshots, and
drops its friendships/blocks/invitations/friend-codes/drafts/pending-codes — all in one
transaction. Chat, feedback and complaints are kept (the tombstone keeps their
no-cascade FKs valid). Session revocation + game forfeit are orchestrated a layer up.
Integration test covers journalling, tombstone/scrub and credential reuse.
2026-07-03 12:01:43 +02:00
Ilia Denisov 12af378b18 feat(account): stamp last-login time + IP on cold app-load
The profile GET (fetched once per cold app-load by the SPA) stamps accounts
.last_login_at/.last_login_ip, throttled to at most once per hour per account
(best-effort, never blocks the read). IP from the gateway-forwarded X-Forwarded-For.
Feeds the account-deletion dossier. Integration test covers the throttle.
2026-07-03 11:54:37 +02:00
Ilia Denisov 1598646021 feat(account): journal detached credentials to the retention log
On unlink (RemoveIdentity, reason=unlink) and email change (replaceEmailIdentity,
reason=change) write the outgoing credential to retained_identities before removing
the live identities row — so the legal dossier survives while the (kind, external_id)
frees for reuse. Same transaction, so the dossier and live state cannot diverge.
Integration tests cover both reasons.
2026-07-03 11:52:48 +02:00
Ilia Denisov 710ab06333 feat(db): retention schema for account deletion (migration 00007)
Additive/expand-contract: new append-only retained_identities table (the legal
dossier of every detached credential, keyed by account_id, TTL'd by detached_at)
plus nullable accounts columns last_login_at/last_login_ip (cold-load stamp) and
deleted_at/deleted_display_name (tombstone + retained real name). Regenerates the
go-jet models for accounts + retained_identities only.
2026-07-03 11:50:14 +02:00
developer 61c7da271c Merge pull request 'feat(account): provider linking, unlink & email change (PR2)' (#163) from feature/email-relay-pr2 into development
CI / changes (push) 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 8s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m51s
2026-07-03 09:16:03 +00:00
Ilia Denisov 029aa2d4cc fix(profile): emoji-presentation envelope icon for the email row
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 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
The bare U+2709 rendered as a mono pseudo-glyph; add the U+FE0F variation selector
so it shows as ✉️.
2026-07-03 11:05:36 +02:00
Ilia Denisov 3f4792a39b test(e2e): sign-in methods matrix — change-email + link/unlink Telegram
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 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m37s
Replace the stale skipped linking specs: change-email refuses a taken address with
the non-disclosing message (never revealing the other account) and replaces a free
one; the web Telegram link control links then unlinks through the confirm dialog.
Chromium + WebKit.
2026-07-03 10:04:54 +02:00
Ilia Denisov 150660819a docs: sign-in methods matrix — unlink + change-email
FUNCTIONAL (+ru): drop the stale 'linking UI hidden' note; document the profile
sign-in-methods matrix, the unlink last-identity guard, and the non-disclosing
atomic email change. ARCHITECTURE: unlink + change-email behaviour and the
purpose=change deeplink branch.
2026-07-03 10:01:54 +02:00
Ilia Denisov 912096a0f1 test(account): unlink guard + change-email replace/refuse/deeplink
Integration: unlinking a provider keeps the other identities and refuses removing
the last one; change-email replaces the address (freeing the old), refuses a taken
address without merging, and works through the one-tap deeplink token. Unit: the
change-email template renders localised ru/en copy.
2026-07-03 09:59:42 +02:00
Ilia Denisov e4fc7f033d feat(profile): sign-in methods matrix — email add/change, provider link/unlink
Profile shows the account's sign-in methods for guests and durable accounts alike:
add or change email, link Telegram on the web (login widget), and unlink a linked
provider. Email is never unlinked (it is changed); unlink is offered only when
another identity remains, and a change to an address owned by another account shows
the non-disclosing 'check the address or contact support'. Add-VK-on-web stays
deferred (no VK OAuth).

Wires linkUnlink / changeEmailRequest / changeEmailConfirm through the client,
transport, mock and codec (encodeLinkUnlink + LinkResult 'unlinked'/'changed'
statuses); codec wire tests; ru/en i18n.
2026-07-03 09:57:05 +02:00
Ilia Denisov b918217497 feat(account): unlink provider + change-email edges (backend + gateway)
Unlink: POST /user/link/unlink (telegram|vk) via account.RemoveIdentity, refusing
the last identity; email is never unlinked. New fbs LinkUnlinkRequest + gateway
link.unlink op, returning the refreshed profile.

Change-email: purposeChange confirm-codes (RequestChangeCode/ConfirmChange) that
atomically replace the account's confirmed email (account.replaceEmailIdentity);
a new address owned by another account is refused without disclosure, never merged.
The one-tap deeplink handles purposeChange too. Reuses the LinkEmail* fbs tables;
gateway link.email.change.{request,confirm} ops + backendclient methods; branded
ru/en change-email copy.
2026-07-03 09:47:42 +02:00
Ilia Denisov a3eb4719de refactor(account): generalize RemoveEmailIdentity to RemoveIdentity(kind)
Generalize the email-erase store op to any identity kind (with the same
last-identity guard and, for email, the pending-confirmation cleanup) so the
profile Unlink control can reuse it for Telegram/VK. RemoveEmailIdentity stays as a
thin wrapper for the admin console.
2026-07-03 09:20:59 +02:00
Ilia Denisov 3a823ca7ef feat(profile): carry linked identities in the profile (email, telegram, vk)
Add email / telegram_linked / vk_linked to the Profile (fbs table + regenerated
Go/TS bindings, gateway ProfileResp + encodeProfile, backend DTO, UI model +
decode). They are filled outside the pure projection — Server.profileResponse now
reads the account's identities (like the banner seam) — and will drive the profile's
Add / Unlink / change-email controls.
2026-07-03 09:17:17 +02:00
developer 76e7916ff6 Merge pull request 'feat(email): one-tap confirm deeplink + client language (PR1b)' (#162) from feature/email-relay-pr1b into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m3s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m40s
2026-07-03 06:58:12 +00:00
Ilia Denisov 54af644429 fix(ui): localise the confirm screen, drop the brand on the error state
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 1m3s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
The session-less /confirm page defaulted to English, so it showed the English
app title. Carry the recipient's language on the deeplink (?lang) and setLocale on
load so the page matches the email. Show a localised brand wordmark (Эрудит / Erudit,
matching the email) on the success state only; the invalid/expired state now shows
just the message, no header.
2026-07-03 05:54:28 +02:00
Ilia Denisov 356bf1a5ba feat(admin): search users by email + erase a bound email
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 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Add an exact (strict) email filter to the /users list (UserFilter.EmailExact →
a kind='email' identity match) with a search input, and an 'Erase email' action on
the user card that deletes the bound email identity and its pending confirmations,
freeing the address. It refuses to remove the account's only identity
(ErrLastIdentity), which would leave it unreachable. Integration tests for both.
2026-07-03 05:30:30 +02:00
Ilia Denisov 1dca6741f1 feat(ui): auto-confirm the email deeplink on load
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
Drop the confirm button: the /confirm screen confirms the token as soon as it
loads. The token rides the URL fragment (never sent to the server), so a plain link
prefetch cannot reach it, and the manual six-digit code is the fallback if an
aggressive scanner runs the page. Update the ARCHITECTURE/FUNCTIONAL wording
accordingly and swap the confirm.prompt/action strings for confirm.busy (en+ru).
2026-07-03 05:18:24 +02:00
Ilia Denisov 77a18a3cc1 fix(account): clear the guest flag on a deeplink email link
ConfirmByToken attached the confirmed email to the account but, on the link path,
skipped ClearGuest — so a guest who bound an email via the one-tap deeplink stayed a
guest (the code-based flow clears it in the link service). Clear the flag on a free
link too, promoting the guest to a durable account; the profile live event then
refreshes the open session. Integration test added.
2026-07-03 05:18:24 +02:00
Ilia Denisov 5804f7266e fix(account): seed the email account display name from the local part
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
An email account was provisioned with no display name (unlike Telegram/VK, which
seed one), so an email login showed an empty name. Seed it from the email's local
part (before '@', trimmed and capped to the column width) on first contact; the
user can rename it later. Only new accounts are seeded — an existing account's name
is never overwritten.
2026-07-03 05:10:22 +02:00
Ilia Denisov 65f2c87a74 docs+test(email): document the confirm deeplink + wire-contract tests
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 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
Document the one-tap confirm deeplink in ARCHITECTURE (§4 the login magic-link /
link confirm+profile-refresh, prefetch-safe token) and the new notify 'profile'
sub-kind (§10), and add the one-tap link to the FUNCTIONAL email story (+ru). Add
codec round-trip assertions for the EmailRequest language field and
encodeEmailConfirmLink (the mock e2e bypasses the codec).
2026-07-03 04:33:27 +02:00
Ilia Denisov 409462fc09 feat(ui): one-tap confirm deeplink screen + client language
Add the /confirm/<token> SPA route and Confirm screen: a prefetch-safe button
POSTs the token via a new confirmEmailLink RPC (client/transport/mock/codec +
ConfirmLinkResult model). A login adopts the minted session and enters the app; a
link shows confirmed / merge-in-the-app; an invalid or expired token asks for a new
code. Exempt /confirm from the no-session /login redirect. Forward the client
locale on the email request (authEmailRequest gains language → app.locale) so a
fresh web login email is localised. Handle the new 'profile' live-event sub-kind by
re-fetching the profile, so a link confirmed in another browser reflects in-app at
once. i18n en+ru.
2026-07-03 04:30:16 +02:00
Ilia Denisov 762155a55e feat(gateway): confirmEmailLink RPC + language on the email request + profile event
Add the confirm-link edge method (auth.email.confirm_link): a new
EmailConfirmLinkRequest/Result fbs table, the transcode const + handler + encoder,
and the backend-client call to the existing /sessions/email/confirm-link endpoint —
it rides Execute under the existing service prefix, so no proto/Caddy change. Add a
language field to EmailRequestRequest and forward it (the backend already seeds it).
Add the NotifyProfile sub-kind + notify.ProfileChanged, published by the confirm-link
handler on a successful link so an in-app session re-fetches its profile when the
email was confirmed in another browser. Regenerated fbs bindings (Go + TS).
2026-07-03 04:22:09 +02:00
Ilia Denisov 25d80bc31d feat(server): confirm-link endpoint for the one-tap deeplink
Add POST /internal/sessions/email/confirm-link: it verifies a deeplink token via
ConfirmByToken and, for a login, mints a session (the deeplink page signs in with
it); for a link, attaches the confirmed email and reports "confirmed" or
"merge_required" (the app drives the interactive merge). The token, not a request
session, is the authorization. Add LinkConfirmation.IsLogin() and integration tests
for the login, link and merge branches (the token is read from the mailed link).
The gateway RPC, live event and SPA route follow.
2026-07-03 04:13:48 +02:00
Ilia Denisov d5b4bba018 feat(account): restore the confirm deeplink token (domain layer)
Re-apply the deeplink backend deferred out of PR1a: migration 00006 adds
email_confirmations.purpose + link_token_hash (hand-edited jet), each issued code
now carries an opaque 256-bit token (only its SHA-256 stored), and ConfirmByToken
resolves a token to a login (confirm + clear guest) or a link (attach when free,
signal merge when owned elsewhere). issueCode now embeds the /app/#/confirm/<token>
link in the email. The confirm endpoint, gateway RPC and SPA route follow.
2026-07-03 04:07:10 +02:00
developer 638c147cc0 Merge pull request 'feat(email): make transactional email live — branded relay, rate-limit, squat fix (PR1a)' (#161) from feature/email-relay-pr1 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 1m3s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m47s
2026-07-03 01:56:55 +00:00
Ilia Denisov c702f1bdac fix(email): explicit TLS mode for non-standard relay ports
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 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The 465-only implicit-TLS heuristic mis-classified Selectel's SSL port (1127),
which the mailer would have dialled with STARTTLS and failed. Add BACKEND_SMTP_TLS
(ssl|starttls) — empty still derives the mode from the port (implicit on 465, else
STARTTLS) — and dial implicit TLS with WithSSL()+WithPort so any port works, not
just 465. Wire SMTP_RELAY_TLS through compose/ci/prod/.env.example and document it
(Selectel: 1127 = SSL, 1126 = STARTTLS). Unit-tested.
2026-07-03 03:44:55 +02:00
Ilia Denisov 01d02fcef6 feat(ui): show the email upgrade box to guests
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m2s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Un-hide the Profile email box for guest accounts (hidden={!p.isGuest}): a guest
binds an email to register / sign in, and a returning address opens the existing
merge dialog. Provider linking stays hidden — the Telegram control keeps its
wiring behind a hidden attribute — until the non-guest linking matrix (PR2). The
two linking e2e specs remain skipped (they assume a non-guest login and the
visible Telegram control); update their stale comments.
2026-07-03 03:18:23 +02:00
Ilia Denisov 9e0f929e40 test+docs(email): squat-fix coverage and the email-pipeline docs
Add an integration test asserting the guest-until-confirmed squat fix (an
email-login account is a reapable guest until the code is confirmed, then
durable). Document the branded relay pipeline in ARCHITECTURE (go-mail, TLS by
port, no client cert, PUBLIC_BASE_URL anti-injection, per-recipient send throttle,
guest-until-confirmed), FUNCTIONAL (+ru), TESTING and the backend README config
table (BACKEND_SMTP_* + BACKEND_PUBLIC_BASE_URL).
2026-07-03 03:13:15 +02:00
Ilia Denisov a29e00ee13 feat(deploy): wire the SMTP relay through every contour
Add the backend confirm-code relay env to compose (BACKEND_SMTP_HOST/PORT/
USERNAME/PASSWORD/FROM + BACKEND_PUBLIC_BASE_URL), sourced from SMTP_RELAY_* /
SMTP_RELAY_FROM / PUBLIC_BASE_URL. An empty host keeps the backend on the log
mailer so a contour without relay credentials still boots. Port defaults to 465
(implicit TLS). Map the TEST_ set in ci.yaml and the PROD_ set in prod-deploy.yaml
(both the deploy-main env and the env.sh heredoc). Document the six variables in
.env.example and the deploy README (secrets: user/pass; variables: host/port/from
+ the per-contour PUBLIC_BASE_URL, required whenever the relay host is set).
2026-07-03 03:09:19 +02:00
Ilia Denisov 2207ac6132 feat(account): throttle confirm-code sends per recipient
Add an in-memory SendLimiter enforcing a one-per-minute cooldown and a
five-per-rolling-hour cap per recipient address, checked before provisioning or
sending in RequestCode, RequestLoginCode and RequestLinkCode. It guards against
email bombing and protects the relay quota. The limiter is injected in main
(nil in tests, so the domain suite is unaffected); ErrTooManyRequests maps to
HTTP 429.
2026-07-03 03:02:49 +02:00
Ilia Denisov 3877b23894 feat(account): brand and harden the email pipeline
Swap the net/smtp mailer for go-mail behind the existing Mailer seam: the
Message struct now carries a text + HTML body, TLS mode is chosen from the port
(implicit TLS on 465, else mandatory STARTTLS), a dial timeout bounds the
synchronous send, and no client certificate is needed. Add a branded, image-free,
mobile-friendly ru/en HTML template (with a plain-text alternative) rendering a
large readable code and an ignore-notice footer with a landing link.

Add BACKEND_PUBLIC_BASE_URL config (the canonical origin for the email footer
link, never the request Host — anti-injection), required when a relay is
configured.

Fix the email-login address squat: ProvisionEmail creates the account flagged
is_guest until the code is confirmed, so an abandoned login is reaped like any
guest and its address freed; confirming (login or link) clears the flag. Seed the
new account's language from the client, plumbed through the email-login request.

The confirm deeplink, its transport surface and the send rate-limit land in
follow-up work.
2026-07-03 02:38:43 +02:00
developer d5fbaa3034 feat(export): server-rendered artifacts behind one signed download URL (#160)
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
The finished-game export (GCG + a new PNG of the final position) is one
signed, short-lived relative URL (game.export_url; HMAC-SHA256, 10-min
TTL, BACKEND_EXPORT_SIGN_KEY) resolved against the client's own origin
and delivered by the best affordance each platform has (five on-device
review rounds):

- TG Android/desktop: native showPopup chooser -> native downloadFile
  dialog (bridge-only chain, activation-safe).
- TG iOS: app-modal chooser -> OS share sheet with the fetched file
  (a popup callback cannot supply the activation the sheet needs).
- VK iOS: VKWebAppDownloadFile for both formats.
- VK Android: the PNG opens in VK's native image viewer, the GCG copies
  to the clipboard (the VK Android downloader hangs on any download,
  Content-Length/Range notwithstanding).
- VK desktop iframe / desktop browsers: plain anchor downloads.
- Mobile browsers: the OS share sheet (fetch-then-share).
- Legacy TG (< Bot API 8.0): app modal + GCG clipboard, no image option.

The PNG is rasterized on demand by the new internal `renderer` sidecar
(node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts)
executing the SAME ui/src/lib/gameimage.ts the ui project unit-tests;
the backend rebuilds the render payload from the journal +
engine.AlphabetTable, and the device date locale, IANA time zone and
localized non-play labels ride the signed URL. Nothing is stored — the
artifact re-derives from the immutable journal on each GET. The gateway
forwards /dl/* (caddy @gateway matcher extended) behind the per-IP
public rate limiter and serves bytes via http.ServeContent.

Deploy: renderer service in compose + prod overlay + rolling order +
prod push list; TEST_/PROD_EXPORT_SIGN_KEY secrets; the sidecar smoke
runs in the ui CI job. Docs: ARCHITECTURE, FUNCTIONAL(+_ru), UI_DESIGN,
TESTING, deploy/README, renderer/README.
2026-07-02 21:58:07 +00:00
developer 16a4431158 Merge pull request 'feat(game): finished-game export as a PNG image behind a format chooser' (#159) from feature/game-export-image 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 1m0s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m21s
2026-07-02 15:29:40 +00:00
developer 422e3ccc2a Merge pull request 'fix(game): no placement auto-zoom in landscape' (#158) from feature/landscape-no-autozoom 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 59s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m20s
2026-07-02 15:19:24 +00:00
Ilia Denisov 946420db93 fix(game): own export chooser modal; PNG option outside in-app webviews only
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 59s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
Telegram's native showPopup delivers its callback with no user
activation, so navigator.share (TG iOS) and clipboard writes (TG
Android GCG copy) silently fail from it — the chooser is now always the
app's own modal, keeping the button click's gesture alive for the
delivery APIs.

The data:URL preview modal is dropped: the Android TG/VK long-press
menu mangles data: URLs (dead download, black-screen open, base64
clipboard garbage), so a binary PNG has no working client-side route in
those webviews at all. The image option is withheld there until the
server-rendered signed-URL delivery (Telegram downloadFile /
VKWebAppDownloadFile) lands; the plain web and mobile browsers keep it.

On-device findings by the owner on the test contour (TG iOS, TG
Android, VK Android).
2026-07-02 17:16:05 +02:00
Ilia Denisov a0eacf3011 feat(game): finished-game export as a PNG image behind a format chooser
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 59s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
The history header's export button now opens a chooser — Telegram's
native popup inside Telegram, the app's own modal elsewhere — offering
the GCG file and a new client-rendered PNG of the final position
(lib/gameimage, Canvas 2D, lazy dynamic import, zero dependencies):
light theme, classic A..O/1..15 axes, label-free premium fills, and a
fixed-typography per-seat scoresheet with GCG-style move coordinates,
multi-word sub-lines, endgame rack-settlement row, winner trophy and a
hostname + device-locale finish date footer; a long game stretches the
board, never the typography.

Delivery mirrors the GCG rules (Web Share with no blob fallback, else
download) except on Android Telegram/VK WebViews and the desktop VK
iframe, where a binary PNG has no clipboard-text fallback: those get a
preview modal with a long-press/right-click save hint and a copy-image
button where ClipboardItem exists.
2026-07-02 16:20:35 +02:00
Ilia Denisov 622d3965a7 fix(game): no placement auto-zoom in landscape
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 59s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m12s
Landscape fits the whole board, so the coarse-pointer auto-zoom on tile
placement, drag hover-hold and hint only hid the rest of the position.
Gate all three on portrait; manual double-tap/pinch zoom is unchanged.
New e2e lock both sides: landscape placement stays unzoomed, portrait
placement still auto-zooms (touch-emulated, both engines).
2026-07-02 15:08:30 +02:00
developer 2ab01ed8f7 Merge pull request 'feat(landing): Russian default + SEO head, icons and OG card' (#157) from feature/landing-seo 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 57s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m15s
2026-07-01 23:29:28 +00:00
Ilia Denisov 1ed624eaf1 feat(landing): Russian default + SEO head, icons and OG card
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 57s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
The landing now always opens in Russian (saved 🌐 choice still wins) —
browser-language detection made the indexed content nondeterministic
(Googlebot renders with en-US). landing.html gains the static Russian
SEO head: title/description, canonical pinned to the production origin,
Open Graph card (Telegram/VK link previews), twitter:card, JSON-LD,
theme-color and the favicon set; the SPA shell turns noindex and its
tab title becomes «Эрудит (Скрэббл)». New assets/icons generator
(same tile design as the VK loader) produces favicon.svg/ico,
apple-touch-icon.png and og-image.png into ui/public/, plus robots.txt.
2026-07-02 01:25:19 +02:00
developer f3e2a6822b Merge pull request 'fix(ui): VK Android external links + landing VK entry + drop 1️⃣ badge' (#156) from feature/vk-links-landing into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 57s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
2026-07-01 22:39:26 +00:00
Ilia Denisov 65063621a9 fix: landing smoke test
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 57s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
2026-07-02 00:29:38 +02:00
Ilia Denisov 4e169c368d chore: i18n game descriptions
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Failing after 1m0s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
2026-07-02 00:24:16 +02:00
Ilia Denisov aec915d5c1 feat(ui): drop the one-word status-bar badge
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 57s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The small 1️⃣ in the status bar's score-preview slot read as noise; the
single-word rule keeps its spelled-out label in the history header and
the lobby invitation card.
2026-07-02 00:10:08 +02:00
Ilia Denisov 5f574a765d feat(landing): VK entry logo + restore the Telegram channel build-arg
Add the VK Mini App logo next to the Telegram one on the landing hero,
linked via the new VITE_VK_APP_LINK build-arg (full URL, wired through
compose, CI and prod-deploy from TEST_/PROD_VITE_VK_APP_LINK).

Also restore the landing's Telegram link itself: commit 57c778f
collapsed the per-language vars to VITE_TELEGRAM_GAME_CHANNEL_NAME in
compose/CI but left gateway/Dockerfile with the stale _EN/_RU ARGs and
without the plain one, so the built bundle saw the var as undefined and
dead-code-eliminated the whole channel-link branch — deployed landings
(prod included) have shown no Telegram logo since.
2026-07-02 00:10:02 +02:00
Ilia Denisov db17287113 fix(ui): route external links out of the Android VK WebView
The Android VK client's WebView ignores target=_blank and navigates the
Mini App's own window to the target, stranding the player outside the
game with no way back (the dictionary lookup, About/Feedback links, the
ad banner and the bot-link modal fallbacks). vk-bridge 3.x has no method
to open an external URL, so external links are routed through VK's own
leave-VK redirect (vk.com/away.php), which the client intercepts
natively and hands to the system browser.

onExternalLinkClick moves from lib/telegram to a new lib/links that
composes the Telegram and VK routers; iOS and desktop VK open _blank
correctly and are left alone.
2026-07-02 00:09:52 +02:00
developer c864147982 Merge pull request 'feat(telemetry): local move-preview adoption metrics (Phase 4)' (#155) from feature/local-eval-telemetry into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 58s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
2026-07-01 21:39:59 +00:00
Ilia Denisov 2e8fa83814 feat(telemetry): local move-preview adoption metrics (Phase 4)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 57s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Measure uptake of the client-side local move-preview accelerator (§5) so
adoption can be watched before defaulting it on: app cold starts, dictionary
loads by result (fetched / cache_hit / miss) and move previews by path
(local / network — the backend load shed).

A small best-effort client beacon (POST /metrics/local-eval, session-gated)
batches counter deltas and posts them on a 60s timer and when the app is
backgrounded — never on the gameplay path: the in-app counters are plain
in-memory increments, only the periodic flush touches the network and it is
fire-and-forget. The gateway folds each batch into three OTel counters
(local_eval_cold_start_total, local_eval_dict_load_total,
local_eval_preview_total), clamped against a spoofed inflation.

- gateway: counters + recordLocalEval + session-gated /metrics/local-eval handler
- ui: localeval-metrics accumulator/beacon; hooks in the dict loader, in
  Game.recompute and in bootstrap (skipped under the mock harness)
- caddy: route /metrics/* to the gateway
- docs: ARCHITECTURE §11; Grafana "Scrabble — Users" dashboard panels
2026-07-01 23:31:49 +02:00
developer 5f87b3fa43 Merge pull request 'feat: on-device move preview (local eval) with network fallback' (#154) from feature/local-eval into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 58s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
2026-07-01 21:02:09 +00:00
Ilia Denisov 5689f7f6a3 feat: on-device move preview (local eval) with network fallback
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Score and validate a tentative move on-device instead of a per-arrangement network
round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a `conformance` CI job (full-dictionary reader
parity plus a battery of plays across every variant and both cross-word rules,
including the inferred orientation). The server stays authoritative — submit_play
re-validates — so the local result is an advisory accelerator only.

- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
  (immutable) streaming the pinned per-game dawg; caddy routes /dict to the gateway.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: the dictionary loads on game open (low priority so it never starves the
  game on a slow link; aborted at a 5s cap or when leaving the game), is cached in
  IndexedDB (best-effort, self-healing on a rejected blob) and reused across
  sessions; a warm-up overlay covers a cold load, then the network preview is the
  fallback; a bad-connection breaker stops warming after repeated misses; the move
  preview cancels its in-flight request when the tiles change.
- parity generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run in
  CI against the release dictionaries. A hidden debug readout lists the cached
  dictionaries + breaker state, and its reset clears the cache.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
2026-07-01 22:58:40 +02:00
developer f0399e1bbc Merge pull request 'build(ui): drop sourcemaps from prod bundle' (#153) from feature/ui-drop-prod-sourcemaps 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 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m16s
2026-07-01 15:00:44 +00:00
Ilia Denisov e6c5198caa build(ui): drop sourcemaps from prod bundle
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
The production UI build shipped `.map` files with full `sourcesContent`, and
the gateway/landing images serve `dist/` verbatim, so anyone could fetch
`/app/assets/main-*.js.map` (same assets under `/vk/`, `/telegram/`) and
reconstruct the entire TypeScript/Svelte source at the edge.

Gate `build.sourcemap` off for `mode === 'production'` (the Docker image build).
Dev and the `mock` e2e build (`vite build --mode mock`) keep maps for debugging.
Document the posture in ARCHITECTURE.md §12.
2026-07-01 16:57:05 +02:00
developer 84cc56198e Merge pull request 'feat(ui): paint VK status bar to the app theme (VKWebAppSetViewSettings)' (#152) from feature/vk-status-bar-chrome 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 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m17s
2026-07-01 12:39:11 +00:00
Ilia Denisov 5f9b4a7a38 feat(ui): paint VK status bar to the app theme (VKWebAppSetViewSettings)
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 58s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m13s
Parity with the Telegram chrome painting: on a VK Mini App launch and on
every theme change, set VK's status-bar appearance (and, on Android, the
action/navigation bar colours) from the app's live theme tokens, so the VK
chrome matches the UI instead of clashing.

syncVKChrome mirrors syncTelegramChrome; the status-bar appearance is
derived from the --bg token's luminance (appearanceForBg, unit-tested).
Wired into the VK onScheme handler (fires on launch + on theme change) and
setTheme. Docs: UI_DESIGN VK integration.
2026-07-01 13:54:14 +02:00
developer 4458f0e545 Merge pull request 'fix(ui): copy GCG to clipboard on Android in-app WebViews' (#151) from feature/gcg-export-android-fix 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 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m13s
2026-07-01 11:23:59 +00:00
developer 41f26da9a7 Merge pull request 'fix(game): clear nudges on game completion' (#148) from feature/nudge-clear-on-completion into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m15s
2026-07-01 11:23:39 +00:00
developer cf1d773c18 Merge pull request 'fix(ui): mobile WebView polish — tap-flash, VK haptics, VK swipe-back' (#149) from feature/mobile-webview-polish 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 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m14s
2026-07-01 11:23:29 +00:00
Ilia Denisov 88b6761e28 fix(ui): copy GCG to clipboard on Android in-app WebViews
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
On-device diagnostics from Android Telegram and VK confirmed both expose
no navigator.share AND no navigator.canShare, so the export fell to a Blob
<a download> that those WebViews silently ignore — nothing happened.

pickGcgDelivery is now a 3-way decision: Web Share where available (iOS),
a clipboard copy in an Android in-app WebView (Telegram/VK: no share, dead
download), else a desktop Blob download. shareOrDownloadGcg reports the
outcome so the game shows a "GCG copied" toast; the copy is VKWebAppCopyText
inside VK (which also covers the desktop VK iframe, where navigator.clipboard
is blocked) and navigator.clipboard otherwise.

Unit tests cover the 3-way choice and the copy/failed outcomes; new i18n key
game.gcgCopied (en+ru); docs ARCHITECTURE/FUNCTIONAL(+ru)/UI_DESIGN/TESTING.
2026-07-01 13:17:00 +02:00
Ilia Denisov f4dbb545e0 fix(ui): suppress tap-flash directly on the header title and back
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
The global -webkit-tap-highlight-color on #app was not enough on Android
in-app WebViews (Telegram, VK): they only suppress the selection-like tap
flash when -webkit-user-select / -webkit-tap-highlight-color /
-webkit-touch-callout sit DIRECTLY on the tapped element, not inherited.
Set them on the two tappable header controls — the title (a 10-tap debug
target) and the back chevron.
2026-07-01 13:06:11 +02:00
Ilia Denisov 71c3411276 fix(ui): mobile in-app WebView polish (tap-flash, VK haptics, swipe-back)
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
- Tap-highlight: add -webkit-tap-highlight-color: transparent on #app.
  Android in-app WebViews (Telegram, VK) flashed a momentary selection-like
  box on tappable nodes on tap — seen on the header title and the back
  chevron; user-select (already none) does not govern it. Inherited, so
  this clears it app-wide.

- VK haptics: mirror the Telegram haptic set on VK via VK Bridge taptic
  (impact / notification / selection), routed through a new shared
  lib/haptics.ts dispatcher. VK users previously got no haptics; the game
  and error call sites now fire haptic() instead of telegramHaptic().

- VK swipe-back: disable VK's horizontal swipe-back at launch
  (VKWebAppSetSwipeSettings history:false) so it does not fight the app's
  own edge-swipe-back and on-board tile drag — parity with Telegram's
  disabled vertical swipes; the app owns navigation via its back chevron.

Docs: UI_DESIGN (no-select / tap-highlight, VK integration).
2026-06-30 23:00:19 +02:00
Ilia Denisov f9acea1d9a fix(game): clear nudges on game completion
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Nudge badges lingered in the lobby on games that ended by turn-timeout,
resignation or forfeit: those paths commit the finish directly, bypassing
the move path's per-mover NudgeClearer, so the awaited seat's nudge was
never marked read. A finished game's nudges are stale, so clear them all.

Add a wired NudgeExpirer (social.ExpireNudges) called from the shared
commit finish block — covering every completion path — and from the
voidGame recovery path. It clears every seat's nudge bits for the game
and leaves chat messages unread; unlike ClearNudges it records no
publish-to-read latency, since a completion is an expiry, not a read.

An integration test reproduces the timeout case (nudge cleared, chat
kept). Docs: ARCHITECTURE §9.1, FUNCTIONAL (+_ru), backend/README.
2026-06-30 22:51:54 +02:00
developer adf7c55695 Merge pull request 'feat(ui): first-run onboarding coachmarks' (#147) from feature/onboarding-coachmarks 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 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m21s
2026-06-30 20:09:33 +00:00
Ilia Denisov 6636d7c309 feat(ui): first-run onboarding coachmarks
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
A one-time coachmark overlay walks a new player through the lobby and their
first game board: a light dimmed layer draws one tail-pointed hint bubble at a
time, advancing on a tap anywhere and removing itself for good after the last
hint. Two independent series (lobby: settings/stats/new game; game:
header/pass-exchange/hints/shuffle/rack), gated by a per-device persisted flag
and marked done only after the last hint, so an interrupted run replays from
the start. A deep-link into Settings -> Friends still triggers the lobby series
on the first trip back to the lobby.

Targets carry a data-coach attribute, so one positioning engine anchors the
bubble in both portrait and landscape, re-measuring each frame until the
geometry settles (route slide, hidden-banner reflow, fonts). The promo banner
hides while the overlay is up (app.coachActive); a hidden DebugPanel "Reset
visited" control replays the walk-through. Off by default in the mock build so
the Playwright smoke is unaffected; ?coach forces it on for the dedicated e2e.

Pure geometry (step lists, nextVisibleStep, placeBubble) in lib/coachmark.ts
(unit-tested); Coachmark.svelte renders. Docs: FUNCTIONAL(+ru) onboarding
story, UI_DESIGN coachmark section.
2026-06-30 21:48:56 +02:00
developer 90f2427fa7 Merge pull request 'feat(assets): VK loading-screen Lottie — bouncing Erudit tile' (#146) from feature/vk-loader-lottie into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m28s
2026-06-30 16:37:26 +00:00
Ilia Denisov d0f860a33e feat(assets): VK loading-screen Lottie — bouncing Erudit tile
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
A 96x96 transparent Lottie for the VK app loading screen: the «Э» tile
(score 8) drops in under gravity, lands with a soft cushion squash
(left/right edges bulge convex, corners follow via a chordal spline),
springs back and loops (~1.1s). A warm glint is caught on the bounce.

Pure 2D shapes (ddd:0) for player compatibility; ~11KB (<24KB limit).
Glyphs are real LiberationSans outlines (Arial-metric, matching the
game's font stack). Includes a reproducible, dependency-light build
pipeline (build/generate.js + extract.js + glyphs.json) and a README.
The preview GIF's green-baize background is preview-only; the asset
itself is transparent.
2026-06-30 18:29:51 +02:00
developer d1ceccf033 Merge pull request 'docs: sync FUNCTIONAL/UI_DESIGN/.claude with the shipped VK + landscape behaviour (#140-#142 follow-up)' (#145) from fix/vk-docs-deeplink-and-pan into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m19s
2026-06-30 06:07:37 +00:00
Ilia Denisov 373aa2aa6d docs: sync VK theme-follow + home-bar safe-area into FUNCTIONAL/UI_DESIGN/.claude
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The #142 polish shipped three VK behaviours the docs never picked up (they were
written for the #140 auth MVP):

- the "auto" theme follows the VK client light/dark (VKWebAppUpdateConfig), not
  the VK webview's prefers-color-scheme;
- the layout clears the VK mobile home bar via CSS env() max'd with the VK bridge
  insets (VKWebAppUpdateInsets) — the bridge value is needed on Android, where the
  VK webview exposes no env() inset (.claude said env() handled it alone);
- share/copy route through the bridge (VKWebAppShare/VKWebAppCopyText).

FUNCTIONAL (+_ru) VK paragraph gains theme+home-bar parity with the Telegram one;
UI_DESIGN gets a VK-integration note beside the Telegram one.
2026-06-30 08:05:38 +02:00
Ilia Denisov 4a6fe318be docs: correct VK invite-link reality + note desktop board drag-to-pan
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Two doc edits that did not land alongside their code:

- FUNCTIONAL (+_ru) claimed a friend invitation is shareable as a Telegram
  *or VK* deep link "that opens it directly". On VK that is false: vkShareLink
  emits a plain vk.com/app<id> link (VK forwards no payload to the Mini App),
  so the recipient enters the copied code by hand (#142, commit d76f1f4 pivoted
  off the earlier #f<code> plan; the doc kept the old wording).
- UI_DESIGN omitted the desktop / landscape-iframe mouse drag-to-pan of the
  zoomed board added in #141 (touch scrolls the viewport natively; a mouse
  cannot, so an explicit drag-to-pan handler moves it).
2026-06-30 07:51:11 +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 00441e3657 Merge pull request 'chore(ci): bump dictionary test-suite seed to v1.3.1' (#143) from chore/dict-seed-v1.3.1 into development
CI / changes (push) Successful in 2s
CI / integration (push) Successful in 14s
CI / deploy (push) Successful in 1m16s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / unit (push) Successful in 9s
CI / ui (push) Successful in 55s
CI / gate (push) Successful in 0s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-30 05:29:44 +00:00
developer 3b7c7c077e chore(ci): bump dictionary test-suite seed to v1.3.1
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The scrabble-dictionary release v1.3.1 drops the obscene lexicon (mat).
Bump the centralised CI seed so unit/integration jobs validate against the
same dictionary the prod deploy now pins (vars.PROD_DICT_VERSION=v1.3.1).
2026-06-30 05:27:42 +00:00
developer e900d592f8 Merge pull request 'feat(vk): native share/copy, auto theme, friend-code deep link, home-bar safe area' (#142) from feature/vk-bridge-integration 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 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m25s
2026-06-29 21:04:53 +00:00
Ilia Denisov d76f1f4026 fix(vk): friend-code link is the plain vk.com/app link (VK strips iframe query payload)
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m33s
The contour diagnostic confirmed VK forwards only the signed vk_* params to the iframe — a
custom payload on the app link is dropped (the '#' eaten by the vk.com SPA, a '?' query
stripped), so the friend-code cannot ride the link. The VK share link is now just
vk.com/app<id>; the recipient enters the copied code by hand (VKWebAppCopyText works). The
vkStartParam reader + bootVK routing stay as a no-op, ready for a post-moderation channel.
Removes the temporary deep-link diagnostic.
2026-06-29 22:58:43 +02:00
Ilia Denisov 0ea9764a0d chore(vk): temporary deep-link diagnostic (remove after contour catch)
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s
2026-06-29 22:41:08 +02:00
Ilia Denisov 6a5ce12fab fix(vk): friend-code link as ?hash, Android safe-area via bridge insets, landscape home-bar colour
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
Contour review of the VK Bridge group:

- #6 invite link: VK's documented '#' direct-link payload is eaten by the vk.com SPA before it
  reaches the app, so the friend-code link now carries the payload as a query param
  (vk.com/app<id>?hash=f<code>); the recipient already reads the `hash` query param (vkStartParam).
  (Whether VK forwards the '?' through to the iframe is being confirmed on the contour.)
- #8 Android: the VK mobile webview does not surface the home-bar inset via CSS env() (config
  insets are iOS-only), so subscribe to the bridge insets (VKWebAppUpdateConfig + VKWebAppUpdateInsets)
  and set --tg-safe-* to max(env(), the VK value).
- #8 landscape colour: the home-indicator strip was the (grey) page background because the two-pane
  landscape game has no bottom bar. The left-panel controls bar now paints its own chrome into the
  inset (Screen gains a selfInset flag that drops the shell's detached padding strip), and the
  game-land runs flush to the edge.

Verified: svelte-check, 347 unit, build, bundle-gate; the landscape safe-area painting reproduced in
the mock (controls bar + board reach the edge, strip takes the bar colour). The VK-Bridge / VK launch
behaviours (Android insets, the ?hash forward) need the live contour.
2026-06-29 22:15:43 +02:00
Ilia Denisov da17c18895 feat(vk): native share/copy, auto theme, friend-code deep link, home-bar safe area
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m25s
Group B of the VK integration — the contour-verified follow-up to the launch+auth MVP:

- Share/copy inside the VK iframe go through VK Bridge: the friend-code invite shares via
  VKWebAppShare and copies via VKWebAppCopyText, since navigator.share is absent in the desktop
  iframe and navigator.clipboard is blocked there.
- The invite link is a VK Mini App direct link (vk.com/app<id>#f<code>) on VK instead of the
  Telegram link; the app id comes from vk_app_id in the launch params (no build arg needed). The
  recipient's launch routes the deep link from VK's `hash` launch query parameter.
- The app's "auto" theme follows the VK client's light/dark appearance (VKWebAppUpdateConfig),
  which the VK mobile webview's prefers-color-scheme does not track.
- The safe-area CSS vars default to env(safe-area-inset-*), so the VK mobile layout clears the
  home bar (and Capacitor/PWA too); Telegram still overrides them from its SDK.

vk.ts adds vkAppId/vkStartParam/vkShare/vkCopyText/vkOnScheme. Verified: svelte-check, 347 unit
(+ vkAppId/vkStartParam/vkShareLink), build, bundle-gate. The VK-Bridge behaviours need the live
contour (not reproducible headless).
2026-06-29 21:27:36 +02:00
developer 303348ed39 Merge pull request 'fix(ui): landscape board zoom/pan + rack tile rendering + mobile block label' (#141) from fix/landscape-board-ui 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 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m31s
2026-06-29 19:11:05 +00:00
Ilia Denisov a06dfe00fc fix(ui): pin the landscape confirm button into the freed tile slot
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m24s
Follow-up to the contour review: with the rack now exactly seven fixed-size slots in the
narrow landscape panel, the 56px confirm button (sized for the roomy portrait rack) was
wider than the single slot a staged tile frees, so it overlapped the now-rightmost tile.
Match the button width to one landscape tile slot, so it sits inside the freed slot with
its right edge level with the rack's right edge — the mirror of the first tile's left edge.
2026-06-29 20:54:44 +02:00
Ilia Denisov a88678c5c6 fix(ui): landscape zoom two-step (sync both axes) + fixed rack tile size
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 2m35s
Follow-up to the contour review of the landscape fixes:

- Zoom still positioned "in two steps": a wide viewport overflows vertically as soon as
  the square board grows past its height, but horizontally only once it grows past the
  (much wider) width — so the browser pins scrollLeft to 0 while the vertical axis
  already pans (no time/ease tween fixes it; the horizontal scroll range physically is
  not there yet). Found by instrumenting the scroll trajectory. Now drive both axes by
  one progress = how far the board has grown past the viewport width (when a horizontal
  pan first becomes possible): until then the board just zooms centred, past it both
  axes pan together in one diagonal motion. Also disable scroll-anchoring so the browser
  stops fighting the programmatic scroll mid-transition. Re-verified: both axes now
  start and move together.
- Rack tiles resized when a tile was placed: landscape used flex-grow, so removing a
  tile regrew the rest. Give them a fixed size (1/7 of the fixed-width rack), like the
  portrait rack, so placing a tile leaves the rest put.
2026-06-29 20:26:01 +02:00
Ilia Denisov 500a01cf97 fix(ui): landscape board zoom/pan + rack tile rendering, shorter mobile block label
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m28s
Bugs surfaced while testing in VK but present on every platform (verified across
browsers, not VK-specific):

- Rack tiles: the letter used a fixed rem size, so in landscape — where tiles shrink
  below 46px in the narrow left panel — it overflowed and dropped into the bottom-left
  corner. Size it relative to the rack (cqw, like the board's labels; the tile's own
  container-query size resolves unreliably under flex + aspect-ratio).
- Landscape board zoom positioned "in two steps": the per-frame clamp-to-max reached
  the wide axis before the tall one. Interpolate both scroll axes together by time over
  the grow/shrink transition instead (settles on zoom-out too).
- The zoomed board could not be panned with a mouse (touch scrolls the overflow:auto
  viewport natively; a mouse cannot drag-scroll a div). Add a drag-to-pan handler, active
  only while zoomed, off pending tiles, past a small movement threshold, swallowing the
  trailing click so it does not also act on a cell.
- Shorten the in-game block-confirm label ru "Блокируем?" -> "В бан?" (the long form
  overflowed the seat score chip on mobile).
2026-06-27 13:46:15 +02:00
developer d9ede77e2f Merge pull request 'feat(vk): embed the game as a VK Mini App' (#140) from feature/vk-embedding into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m30s
2026-06-27 11:23:44 +00:00
Ilia Denisov 65c194264c feat(vk): embed the game as a VK Mini App
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 1m0s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Mirror the Telegram Mini App wrapper for VK: the SPA loads at a new /vk/
entry, authenticates from VK's signed launch parameters, and provisions a
'vk' platform identity — the minimum to run the game in VK test mode.

- Gateway verifies the launch signature in-process (internal/vkauth:
  HMAC-SHA256 over the sorted vk_* params under GATEWAY_VK_APP_SECRET,
  base64url) — a pure offline check, no side-service. New auth.vk op
  (gated on the secret), backendclient.VKAuth, /vk/ SPA mount.
- Backend: KindVK + ProvisionVK/vkSeed, /sessions/vk handler, identity
  kind widened to include 'vk' (migration 00005, expand-contract).
- UI: src/lib/vk.ts (VK Bridge, lazy-imported), bootVK + the /vk/ boot
  dispatch, encodeVKLogin + authVK across transport/client/mock. VK omits
  the name from the signed params, so the client reads it via
  VKWebAppGetUserInfo as an unsigned display seed.
- Deploy: /vk in the edge Caddyfile, GATEWAY_VK_APP_SECRET wired through
  compose + .env.example + CI (TEST_) + prod-deploy (PROD_).
- Admin console: surface the VK user id (link to the VK profile) next to
  the Telegram id on the user card.
- Docs: ARCHITECTURE §12/§13, FUNCTIONAL (+ _ru), gateway README; VK
  integration reference under .claude/.

Signature algorithm verified against dev.vk.com plus independent Node/Python
references and a %2C edge-case vector.
2026-06-27 11:37:31 +02:00
developer 13c22734ee Merge pull request 'chore: track the deploy-check skill in the repo' (#139) from chore/deploy-check-skill into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m26s
2026-06-24 13:07:22 +00:00
Ilia Denisov 7dabcd1317 chore: track the deploy-check skill in the repo
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m23s
The deploy-check skill is repo-specific: it encodes this project's hard-won
pre-deploy runtime constraints (distroless nonroot, edge Alt-Svc/HTTP3, caddy
recreate, DICT_VERSION boot, expand-contract migrations, the Telegram permission
model) and points at deploy/README.md, docs/ARCHITECTURE.md, docs/EDGE_HTTP3.md and
the agent memory files. It belongs with the deploy logic it guards, not in the global
config, so it travels with the repo and stays versioned alongside it.
2026-06-24 14:39:00 +02: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 b03e012011 Merge pull request 'feat(telegram): native dialogs (experimental, stacked on #136)' (#137) from feature/telegram-native-dialogs into development
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
CI / changes (push) Successful in 2s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m16s
CI / unit (pull_request) Successful in 10s
2026-06-24 11:41:28 +00:00
developer 2495446a47 Merge pull request 'feat(telegram): Mini App embedding enhancements' (#136) from feature/telegram-embedding 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 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m19s
2026-06-24 11:41:15 +00:00
Ilia Denisov 35705f7d1e fix(telegram): defer deep-link notices until the loading cover clears
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m28s
The stale-invite / welcome-on-redeem notices are raised during boot, so the native
popup fired over the loading splash. Gate both the native popup and the in-app Modal
on the current route's loading cover being gone — the tile splash on the lobby
(splashDone), the plain loading screen elsewhere (app.ready) — so the notice appears
with the settled screen on every build (Telegram and native/web alike).
2026-06-24 13:25:14 +02:00
Ilia Denisov 4f0cc81dfb fix(telegram): evaluate native-dialog availability at fire time
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
The deep-link info modals (stale invite, welcome-on-redeem) captured
insideTelegram() && telegramDialogsAvailable() in a const at component init, but they
mount in App before bootstrap loads the SDK, so the value was always false and they
fell back to the in-app Modal even inside Telegram. Evaluate it at fire time (in the
effect and the {#if} guard), as the destructive confirms already do at click time.
2026-06-24 13:16:11 +02:00
Ilia Denisov 2ba7cc3086 feat(telegram): native dialogs for confirms and deep-link notices
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Inside the Mini App, route the destructive confirmations (resign, block, unfriend)
through Telegram's native showConfirm, and present the deep-link info modals (stale
invite, welcome-on-redeem) as a native showPopup whose button opens the bot chat.
Outside Telegram, or on a client predating the dialogs, the existing in-app Modal is
used unchanged; offline also keeps the modal so the action retains its disabled state.

Adds showConfirm/showPopup wrappers to telegram.ts and a pure popup-params builder
(nativedialogs.ts) with unit tests; the deep-link modal components choose native vs
in-app via an effect gated on insideTelegram + dialog availability.
2026-06-24 12:36:52 +02:00
Ilia Denisov 29b6c7e4d8 docs(telegram): document the Mini App embedding enhancements
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
Record the current state in ARCHITECTURE and FUNCTIONAL (+ _ru mirror): inside the
Mini App the client tracks Telegram's live theme switch, fits the full device
safe-area, exposes a native Settings button into the in-app settings, and syncs the
device-independent display prefs (theme, reduce-motion, board labels — not the
interface language) across the user's Telegram devices via CloudStorage; the
validator denies a bot user (is_bot) before provisioning an account.
2026-06-24 12:19:50 +02:00
Ilia Denisov 8de9fb1ecd feat(telegram): deny bot users at initData validation
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The validator parsed only id/username/first_name/language_code from the signed
Telegram user, so a WebAppUser flagged is_bot would have been provisioned a normal
account. The HMAC already proves Telegram signed the payload, so is_bot==true is
Telegram itself attesting the launching principal is a bot.

Parse is_bot and reject it (ErrInvalidInitData -> gateway 4xx -> launch error). A
real user opening the Mini App never carries it, so this is a defensive deny. Tests
cover both the denied (is_bot true) and allowed (is_bot false) paths.
2026-06-24 11:55:30 +02:00
Ilia Denisov 8a5a5d6c4d feat(telegram): sync client display prefs via CloudStorage
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
Theme, reduce-motion and board labels lived only in local IndexedDB/localStorage,
so they did not follow the user across devices and could be lost when the Telegram
WebView cleared storage.

Mirror these three device-independent prefs to Telegram CloudStorage (Bot API 6.9)
from the single local-persist point (persistPrefs), and reconcile them from
CloudStorage in the background on launch (reconcileCloudPrefs) so a change made on
another device follows the user here. The local store stays the instant-render
cache; the interface language is intentionally excluded (it syncs via the durable
account). Pure encode/decode extracted to cloudprefs.ts with unit tests; the
CloudStorage transport wrappers are added to telegram.ts. No-op outside Telegram or
on a client predating CloudStorage.
2026-06-24 11:39:29 +02:00
Ilia Denisov ea931c6680 feat(telegram): native SettingsButton opens our Settings screen
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
Inside the Mini App, reveal Telegram's native Settings button (Bot API 7.0)
and route its taps to the Settings screen — the standard Mini App affordance.
The in-app gear entry stays the primary path (two entry points by design).
No-op outside Telegram or on a client predating the button.
2026-06-24 10:47:05 +02:00
Ilia Denisov d0f60ee41d fix(telegram): paint the home-indicator strip with the bottom bar's colour
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m15s
The safe-area bottom inset was reserved on the Screen wrapper, so the strip
that holds space for the home indicator showed the content background and read
as detached from the coloured bottom bar / header above it.

Move the bottom inset onto the bottom bar: the Screen .tabbar wrapper now paints
--bg-elev and pads itself by --tg-safe-bottom, so the strip continues the
TabBar's chrome; a screen with no tab bar pads its bottom-most content
(.content:last-child) instead, so the strip takes that content's own colour.
2026-06-24 10:41:26 +02:00
Ilia Denisov b84bd1297e feat(telegram): clear bottom and side safe-area insets
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Only the device safe-area TOP inset was mirrored, so on phones with a home
indicator the rack / bottom bar sat under it, and in landscape the notch
clipped the screen edges.

Mirror the full device safe-area inset (bottom / left / right) into new
--tg-safe-bottom / --tg-safe-left / --tg-safe-right CSS vars (0 outside
Telegram) and pad the shared Screen wrapper by them, so every screen clears the
home indicator and the landscape notch; the top inset stays owned by the header.
Replace telegramSafeAreaTop with telegramSafeAreaInset (the full inset object),
with a unit test.
2026-06-24 10:24:56 +02:00
Ilia Denisov 0fb6004a8b feat(telegram): re-apply theme live on themeChanged
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
The Mini App read Telegram's themeParams/colorScheme only once at launch, so
switching Telegram's light/dark theme (or its auto day/night) while the app was
open left the SPA on stale colours until a relaunch.

Subscribe to the themeChanged WebApp event and re-apply the theme live. Extract
the launch-time token + colour-scheme + chrome application into syncTelegramTheme
(reading live themeParams when no launch snapshot is passed) and call it from both
applyTelegramChrome (launch) and the new event handler. Add a telegramThemeParams
live getter (+ unit test). Drop the stale 'immersive fullscreen' note from
applyTelegramChrome — the app deliberately does not request fullscreen.
2026-06-24 09:20:41 +02: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 c1d1c1624b Merge pull request 'feat(telegram): promo deep-link seeds EN variant (+ UI: share label, header padding)' (#134) from feature/promo-deeplink-and-ui-nits into development
CI / unit (push) Successful in 10s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / changes (push) Successful in 2s
CI / integration (push) Successful in 16s
CI / deploy (push) Successful in 1m18s
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-23 20:50:14 +00:00
Ilia Denisov 9207664fbd docs(telegram): note the promo body @username is a deep link
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
2026-06-23 22:45:54 +02:00
Ilia Denisov a4581663f4 feat(telegram): link the promo body @username to the Mini App deep link
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
The promo message body now renders "@<bot>" as an HTML text_link to the same ?startapp deep link the button uses (ParseMode HTML), so tapping the mention opens the seeded Mini App instead of the bot profile. Same payload (campaign start-param, else the forwarded /start payload) backs both the button and the mention.
2026-06-23 22:40:52 +02:00
Ilia Denisov 03dfc29a54 feat(telegram): promo deep-link seeds English Scrabble for new users
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 50s
The promo bot button carries a configurable variant-seed start-param (default verudit_ru-scrabble_en). The gateway parses start_param from the validated initData and forwards it; the backend, on first contact only, seeds the new account variant_preferences from it (English Scrabble alongside the default Erudit).

No schema change (the scrabble_en CHECK is already in the baseline) and the gateway<->backend REST field is additive, so the rolling deploy is safe in either order. TELEGRAM_PROMO_START_PARAM configures the payload (empty forwards the user own /start payload). Covered by account unit tests, a gateway transcode test, and an integration test asserting new-only seeding.
2026-06-23 21:51:52 +02:00
Ilia Denisov c02262fcf7 style(ui): halve custom header top/bottom padding
The custom title bar was too tall. Halve the standard bar padding (10->5px) and, in the Telegram path, the notch gap (16->8px) and bottom (6->3px); the notch safe-area inset is unchanged. Title and back chevron stay vertically centred.
2026-06-23 21:51:52 +02:00
Ilia Denisov f8fab4a4c2 fix(ui): rename Friends "Share via Telegram" to "Share"
The share button uses the OS Web Share sheet outside Telegram (the TG share picker only inside it), so "via Telegram" was misleading. Rename to a neutral "Share"/"Поделиться" and drop the now-unused tgshare class.
2026-06-23 21:51:52 +02: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 10264e10c8 Merge pull request 'fix(telegram): support relay — text_mention card + reopen deleted topic' (#132) from feature/telegram-support-card-mention into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m17s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-23 16:51:04 +00:00
Ilia Denisov fc1715128e fix(telegram): reopen a deleted support topic instead of losing it to General
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Telegram silently routes a copyMessage aimed at a deleted forum topic
into the chat's General topic with NO error, so the bot's error-based
recreate never fired — the user's next message landed in General with no
card. Confirmed against the prod bot logs: a relay after a topic deletion
logged neither "relay to topic failed" nor "topic gone, reopening".

Probe topic liveness before reusing it: re-applying the info card's reply
markup is a no-op that errors only when the card (hence the topic) is
gone, so the bot detects the deletion and reopens the topic + card rather
than relying on the (absent) copy error. The post-copy recreate stays as
a race backstop.
2026-06-23 18:45:39 +02:00
Ilia Denisov bb18dc362b fix(telegram): support card — text_mention name, no command/dead link
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The topic info card built the profile link as `tg://user?id=`, which
clients render as dead text for a user they don't already know (the
test-vs-prod difference an operator saw), and it put the raw display
name through HTML — so a name starting with "/" was auto-detected as a
tappable bot command that fired when tapped.

Render the card with message entities instead: the display name is
covered by a `text_mention` entity (the reliable way to mention a
username-less user the bot has already seen — it messaged the bot),
which links the profile AND, because the name sits inside an entity,
suppresses the "/command" and "@mention" auto-detection on it. Drop the
HTML parse mode and the tg:// link. Entity offsets are UTF-16.
2026-06-23 18:33:14 +02: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 d86e022373 Merge pull request 'feat(telegram): bot support relay — per-user forum topics' (#130) from feature/telegram-support-relay into development
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m22s
CI / changes (push) Successful in 2s
CI / ui (push) Successful in 56s
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-23 16:08:18 +00:00
Ilia Denisov 6a602aefae feat(telegram): bot support relay — per-user forum topics
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
Users who DM the bot anything but /start are relayed into a private
forum supergroup, one topic per user. Operators (the chat's admins)
reply in the topic and the bot copies it back to the user; an info card
opening each topic carries a Block/Unblock toggle and a Clear button.
State is a small JSON file on a new /data volume — the bot host has no
database. Off by default (TELEGRAM_SUPPORT_CHAT_ID=0): the prod bot is
unchanged until the operator sets the chat id and adds the bot as a
forum admin.

- internal/support: concurrency-safe JSON store (topic map, block list,
  relayed message ids) with field-targeted mutators and atomic save
- bot/support.go: relay both ways via copyMessage, short-TTL admin
  cache, callback buttons, per-user topic-create lock, loop guard
  (skip the bot's own posts), reopen a deleted topic on the next message
- config + compose + CI/prod-deploy: TELEGRAM_SUPPORT_CHAT_ID per
  contour + TELEGRAM_SUPPORT_STATE_DIR; bot-state named volume; /data
  pre-owned by UID 65532 so a fresh volume is writable under distroless
- docs: ARCHITECTURE §15 + decision record, FUNCTIONAL (+ru), README
2026-06-23 17:47:00 +02: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 e6277dcd43 Merge pull request 'fix(ui): Android Telegram nav — windowed mode, no close guard' (#128) from feature/telegram-android-nav-fixes into development
CI / changes (push) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
CI / changes (pull_request) Successful in 2s
CI / deploy (pull_request) Has been skipped
2026-06-23 13:14:04 +00:00
Ilia Denisov 37070c3cb7 feat(ui): drop Telegram fullscreen; own back chevron everywhere; hidden debug panel
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m13s
Finalises the Telegram Mini App navigation work after on-device testing (Pixel
10 / Android 17 + iOS, fresh beta clients):

- Remove requestFullscreen entirely. Immersive fullscreen hid Telegram's native
  header (and its BackButton) and the Android system swipe-back minimised the
  app; the owner prefers the windowed full-size (expand) presentation, so the
  app never requests fullscreen on any platform now.
- The app's own back chevron (Header, showBack = !!back) drives back-navigation
  on every platform; the native Telegram BackButton is dropped — it does not
  render in the windowed Mini App (backVisible=false on iOS and Android), so
  relying on it lost back navigation (notably none on iOS).
- Replace the temporary always-on diagnostic overlay with a hidden debug panel
  (components/DebugPanel): ten quick taps on the header title open it; it shows a
  privacy-safe client diagnostic snapshot (app version, locale, online, userId,
  Telegram chrome / viewport / SDK state — no secrets, no IP) and shares it via
  the OS share sheet / clipboard; a tap anywhere except Share dismisses it.
- Drop the now-dead telegramRequestFullscreen / telegramBackButton /
  isTelegramAndroid helpers and the iOS-fullscreen unit test.

Telegram has no native non-modal notification API (only modal showPopup /
showAlert), so in-app toasts stay ours. Docs: UI_DESIGN.md.
2026-06-23 15:05:13 +02:00
Ilia Denisov 53d6883ffd fix(ui): own back chevron in Telegram on all platforms; drop the native BackButton
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
The Telegram native BackButton does not render in the windowed Mini App (the
owner's emulator + a fresh beta TG report backVisible=false on both iOS and
Android), so relying on it lost back navigation — iOS had no back affordance at
all. Show the app's own back chevron whenever there is a back target, on every
platform (Header showBack = !!back), and drop the now-dead native BackButton
effect (App.svelte). The native close control stays — a windowed Mini App
cannot hide it (no Telegram API).

WIP: the temp lobby diagnostic overlay and the requestFullscreen no-op remain
for the owner's emulator test; finalize after confirmation.
2026-06-23 14:37:43 +02:00
Ilia Denisov 93c57b3558 test(ui): TEMP-skip the iOS fullscreen unit test (requestFullscreen is a no-op for the owner test; restore with the iOS path)
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
2026-06-23 14:09:40 +02:00
Ilia Denisov 6f00c2f41d chore(ui): TEMP disable fullscreen for testing + Android back chevron + BackButton diag
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) Failing after 8s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
WIP for the Android nav investigation (TEMP bits reverted before merge):
- telegramRequestFullscreen: temporarily a no-op (incl. iOS) so the owner can
  confirm the Android "fullscreen look" is Telegram's own Mini App presentation,
  not our requestFullscreen (isFullscreen is already false on Android).
- Header: show the app's own back chevron in Telegram on Android, where the
  native BackButton does not render — a reliable tap-back. [keep]
- Diagnostic overlay moved app-wide (pointer-events:none) and now reports
  BackButton state (req/present/visible) + viewport geometry, to see whether the
  native BackButton can capture the Android system swipe-back.
2026-06-23 14:04:21 +02:00
Ilia Denisov 79766438a2 chore(ui): TEMP lobby diagnostic for the Android fullscreen issue
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Renders Telegram viewport/fullscreen state (isFullscreen, isExpanded, viewport
heights, innerH vs screenH, safe-area insets) in the lobby, inside Telegram
only, to diagnose why the app still opens fullscreen on Android with
requestFullscreen now iOS-only and no persisted state (fresh TG + test account).
REVERT before merge.
2026-06-23 13:36:54 +02:00
Ilia Denisov 6aa5023b24 fix(ui): Android Telegram nav — windowed mode, no close guard
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Three Android Mini App issues, all in the Telegram chrome:

- Entering a game showed no native back button, and the Android system
  swipe-back minimised the app instead of navigating. Root cause: immersive
  fullscreen (requestFullscreen). On Android, fullscreen replaces the native
  header — and its BackButton, which also captures the system back — with a bare
  close/menu pill, so back navigation has no control to land on. Request
  fullscreen on iOS only; Android stays windowed, keeping the native header +
  BackButton (and the system swipe-back that routes to it). iOS is unchanged.

- Closing the game board always prompted "changes that you made may not be
  saved", even on a board just opened and untouched. The close-confirmation was
  armed unconditionally on game mount. Remove it entirely: move drafts auto-save
  (debounced during play + flushed on destroy), so nothing is lost on close.

Drops the now-unused telegramClosingConfirmation + isMobilePlatform helpers and
their SDK interface fields. Docs: UI_DESIGN.md.
2026-06-23 11:48:38 +02: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 508dc870ec Merge pull request 'feat(ui): diagnostic screen on /telegram/ instead of the landing bounce' (#126) from feature/telegram-launch-diagnostic into development
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
2026-06-23 08:32:18 +00:00
Ilia Denisov e3899d4755 feat(ui): record the SDK load outcome in the launch diagnostic
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
The diagnostic showed only sdk: yes/no (window.Telegram presence), not why the
SDK was absent. Capture how the dynamic telegram-web-app.js load resolved —
present / loaded / no-webapp / error / timeout — and surface it as
"sdk-load: <outcome>". error/timeout pinpoint a blocked or hanging telegram.org
(the prime suspect for an empty launch); no-webapp a loaded-but-broken script.

loadTelegramSDK records the outcome (telegramSdkOutcome); collectTelegramDiag
carries it into the screen. Unit tests cover each outcome; the blocked-script
e2e now asserts sdk-load: error.
2026-06-23 10:18:02 +02:00
Ilia Denisov ae5090b851 fix(ui): load telegram-web-app.js dynamically with a timeout
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The Telegram SDK was a render-blocking <script src="telegram.org/..."> in the
shared index.html shell, so it ran on every entry (/telegram/, /app/, native).
On a network that blocks telegram.org — common where Telegram itself reaches
users only over a proxy — the script hangs forever, stranding the whole page,
including the launch-diagnostic screen meant to surface exactly this failure.
This is the likely root cause of the Android "won't open" reports (all launch
methods fail identically; iOS on a different network works).

Remove the head <script> and load the SDK dynamically (lib/telegram.ts
loadTelegramSDK) with a 10s timeout, only on a Telegram entry (the /telegram/
path or a tgWebApp launch fragment). The SPA — served from our own reachable
origin — boots first and controls the load: on a block/error it falls through
to the diagnostic screen (reporting sdk: no) instead of hanging, and Retry
re-attempts. /app/ and the native build no longer touch telegram.org.

Pin the SDK to the version the official page recommends (?62) for the newer
client features the app already uses (fullscreen, safe areas, swipe guard).

Tests: loadTelegramSDK unit tests (present / error / timeout); an e2e that
aborts the script fetch and asserts the diagnostic still renders.
2026-06-23 09:59:24 +02:00
Ilia Denisov 0c5d3808d7 feat(ui): diagnostic screen on /telegram/ instead of the landing bounce
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
A Mini App launch on /telegram/ without sign-in data (empty initData) used to
location.replace('/') — bouncing the visitor to the marketing landing. That
destroyed all diagnosability and, on some Android clients that reach the entry
with no initData, simply looked like the app refusing to open.

Replace the bounce with a compact, privacy-safe launch-error screen
(screens/TelegramLaunchError.svelte) that renders a one-screenshot diagnostic
snapshot captured at the moment of failure: SDK/WebApp presence, Telegram
platform/version, whether initData is empty, whether the URL fragment carried
tgWebAppData, the initData field NAMES present/missing (never the signed
values, never an IP), and OS/mobile/browser via User-Agent Client Hints plus
the full User-Agent. A Share button delivers it through the OS share sheet
(clipboard copy on desktop, reusing the GCG no-webview-strand guard); Retry
re-checks in place to recover a late initData without a reload (which would
discard the launch fragment).

This is the instrument to root-cause the Android empty-initData failure, which
is not reproducible in Playwright.

Tests: collectTelegramDiag + shareText/pickTextShare unit tests; the /telegram/
e2e now asserts the diagnostic screen, not a redirect. Docs: ARCHITECTURE.md +
UI_DESIGN.md updated.
2026-06-23 09:37:36 +02:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer 18db62e19d Merge pull request 'feat(ui): friends list as lobby-style rows with kebab + confirm modals' (#123) from feature/friends-list-kebab-confirm into development
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / deploy (push) Successful in 1m18s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 22:28:25 +00:00
Ilia Denisov d4e34efa80 test(ui): e2e for the friends-list kebab, confirm modals and outside-tap
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
Cover the reworked Settings -> Friends interactions in the mock e2e
(Chromium + WebKit): the row kebab slides open the block/remove icons and an
outside tap collapses it; blocking confirms (naming the friend) and moves them
to Blocked; removing confirms and drops the friendship.
2026-06-23 00:24:33 +02:00
Ilia Denisov 12ff6dad86 feat(ui): close the friends kebab on an outside tap
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
A slid-open friend row now collapses when the user taps anywhere outside its
action buttons (taps on a kebab are skipped so its own toggle still drives the
open/close). Uses the same capture-phase window pointerdown idiom as Screen,
active only while a row is revealed.
2026-06-23 00:20:09 +02:00
Ilia Denisov 5a80696fe8 feat(ui): friends list as lobby-style rows with kebab + confirm modals
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 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m31s
Settings -> Friends previously rendered each friend as a bordered card with
two always-visible text buttons (Remove / Block) that fired immediately. Rework
the whole screen to the lobby's visual language: one-line rows split by
hairline separators across all three sections (friends, incoming requests,
blocked).

Each friend row gains a right-hand kebab that slides the row open to reveal two
icon actions split by a vertical divider -- block (no-entry) and remove (cross)
-- mirroring the lobby's slide-to-reveal. Both actions now require a
confirmation modal; since the slide moves a short name off-screen, the modal
keeps a generic title and shows the friend's name in the body, above the
buttons, so a long name cannot stretch the sheet. Incoming keeps its
accept/decline buttons and blocked keeps unblock, inline on their rows.

Add the friends.actions / friends.blockConfirm / friends.unfriendConfirm keys
to both i18n catalogs and document the flow in FUNCTIONAL (+_ru).
2026-06-23 00:09:39 +02:00
developer d6401bb76c Merge pull request 'fix(deploy): force-recreate caddy on its roll so config-only changes apply' (#122) from feature/prod-deploy-force-recreate-caddy 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 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m17s
2026-06-22 20:09:43 +00:00
Ilia Denisov e0a5753f1a fix(deploy): force-recreate caddy on its roll so config-only changes apply
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
The prod rolling deploy rolls each service with `compose up -d --no-deps <svc>`.
For caddy that is a no-op on a config-only release: its image is pinned
(caddy:2-alpine, no $TAG), so the compose definition is unchanged between
releases, compose treats the container as current and does not recreate it, and
admin is off so there is no hot reload. The new bind-mounted Caddyfile is seeded
to the host but never loaded -- the v1.2.2 `Alt-Svc: clear` edge fix deployed
green yet did not take effect until caddy was restarted by hand.

Force a recreate for caddy on its roll (every other service already recreates on
its new $TAG image), so a bind-mounted Caddyfile change always applies. Costs a
~1-2s caddy blip per deploy, acceptable for the infrequent manual prod rollout.
2026-06-22 22:03:35 +02: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 ba57687430 Merge pull request 'fix(grafana): real byte thresholds for the Database size stat' (#120) from feature/grafana-db-size-thresholds into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 54s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m20s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 19:41:42 +00:00
developer 6cb88b28c4 Merge pull request 'fix(edge): suppress dead HTTP/3 advert with Alt-Svc: clear' (#119) from feature/edge-suppress-http3-altsvc into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 55s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
2026-06-22 19:41:16 +00:00
Ilia Denisov 46d569720c fix(grafana): give "Database size" stat real byte thresholds
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The "Database size" stat had no thresholds, so Grafana applied its stat
default (green base, red at >=80). The query is pg_database_size_bytes, so a
healthy ~9 MiB database (9.4M >> 80) rendered permanently RED on the
Scrabble - Resources dashboard (test + prod), reading as a false alert; the
neighbouring percentunit cache-hit stat stayed green only because its 0..1
values fall under 80.

Add absolute byte thresholds sized to the 40 GiB prod disk (4.6 GiB used,
observability bounded -- Tempo <=1 GiB, Prometheus 7d -- so the DB is the
only unbounded grower): green up to 8 GiB, yellow at 8 GiB (~20% of disk),
red at 16 GiB (~40%), an early warning with ample runway before the disk
tightens, not a panic line. Cosmetic panel coloring only; there are no
Grafana alert rules provisioned.
2026-06-22 21:35:48 +02:00
Ilia Denisov 9253b1bdca fix(edge): suppress dead HTTP/3 advert with Alt-Svc: clear
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
Caddy enables HTTP/3 by default on any TLS listener and emits
Alt-Svc: h3=":443"; ma=2592000, but UDP/443 is never reachable: the prod
compose maps only "443:443" (TCP) and ufw opens 443/tcp (test contour: the
host caddy publishes only :443/tcp). A client that cached the 30-day advert
tries QUIC first on later opens, gets no response, and waits for the QUIC
attempt to time out before falling back to h2 -- which surfaced as the
Telegram Mini App intermittently hanging on load (a barely-noticeable pause
up to a blank window). The h2/TCP serving path itself is healthy (~10ms TTFB).

Emit Alt-Svc: clear site-wide at the contour caddy so clients actively drop
any cached alternative and stay on h2/h1. This caddy terminates TLS in prod
(the fix target); in the test contour it serves plain :80 and the host caddy
re-stamps its own Alt-Svc, so the live test fix lives in the host caddy. Add
docs/EDGE_HTTP3.md (symptom, diagnosis method, verify, and option B -- serving
h3 for real -- if it recurs) and link it from ARCHITECTURE.md.
2026-06-22 21:20:31 +02: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 9d1ca213d6 Merge pull request 'fix(i18n): banner/push follow the interface language even without a Settings change' (#118) from feature/banner-language-followup into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 54s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m16s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 18:17:03 +00:00
developer 1f78bb274b Merge pull request 'feat(telegram): localized /start welcome with channel & chat follow links' (#117) from feature/bot-welcome-localized into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Has been skipped
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 54s
CI / gate (push) Successful in 0s
CI / gate (pull_request) Successful in 0s
CI / deploy (push) Successful in 1m21s
CI / deploy (pull_request) Has been skipped
2026-06-22 18:16:52 +00:00
Ilia Denisov 81b716569f fix(i18n): reconcile preferred_language to the interface locale on every adopt
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
A user who never changed the language in Settings kept their account at the
creation-time preferred_language seed (e.g. en from the Telegram launch language_code)
even after switching the device to another language: the UI followed the device (ru) but
the ad banner and out-of-app push — both resolved server-side from preferred_language —
stayed en. The on-adopt reconcile was gated on an explicit local choice (localeLocked),
so a system-guess locale was never pushed through.

Reconcile preferred_language to the active interface locale (app.locale) on every session
adopt and link, regardless of how the locale was chosen; persistLanguageToServer already
self-gates (a no-op for guests and when already equal), so there is no steady-state write.
The banner and push are the only server-rendered language surfaces and both read
preferred_language, so this keeps the whole interface consistent — not just the banner.
Drop the now-dead localeLocked flag (the reconcile guards were its only readers; the saved
prefs.locale still restores the UI choice per device).

Trade-off: preferred_language now follows the most-recently-opened device, so an explicit
choice on one device can be overwritten by a system guess on another (the "explicit" mark
is local, per-device); making it globally sticky would need a DB flag.

Docs: ARCHITECTURE §4 + the profile field.
2026-06-22 20:09:41 +02:00
Ilia Denisov aa330b726e feat(telegram): localized /start welcome with channel & chat follow links
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
The main bot answered /start with a single English line ("Tap to open Scrabble.").
Localize it: Russian or English by the sender's reported Telegram language
(Message.from.language_code, which the Bot API carries on the message itself — there is
no separate user-update event — English fallback), with the longer welcome copy and a
localized launch button ("Открыть «Эрудит»" / "Open “Erudite”").

The welcome links the game channel and the discussion chat by their public @username,
resolved once at startup from the configured TELEGRAM_GAME_CHANNEL_ID / TELEGRAM_CHAT_ID
via getChat and cached. A handle that is unset, private, or unreadable degrades to a
generic noun ("the channel" / "our chat") rather than a dangling "@", so the paragraph
always reads cleanly (the bot's info screen still lists the real links). Adds
GameChannelID to bot.Config (wired from the existing config) for the channel handle.

Tests: startText localization + handle embedding + per-slot generic fallback; handleStart
language selection; resolveWelcomeHandles. README updated.
2026-06-22 19:39:00 +02:00
developer d5369a0188 Merge pull request 'feat(account): seed the time zone from the client's detected offset at creation' (#116) from feature/account-seed-timezone into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 54s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 17:07:53 +00:00
developer 170a6ae9ef Merge pull request 'feat(feedback): capture app version + browser zone; Filed time in three zones' (#115) from feature/feedback-version into development
CI / changes (push) Successful in 1s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 55s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 53s
CI / gate (push) Successful in 0s
CI / gate (pull_request) Successful in 0s
CI / deploy (push) Successful in 1m21s
CI / deploy (pull_request) Has been skipped
2026-06-22 17:07:21 +00:00
Ilia Denisov ef2c2d1eb9 feat(account): seed the time zone from the client's detected offset at creation
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
A new account's time_zone defaulted to 'UTC' until the player saved a profile, so the
robot's sleep window and the turn-timeout away-window sweeper — both anchored to the
account zone via account.ResolveZone — ran on UTC for every fresh player, skewing
robot-game timing until a manual Settings save. Seed the zone at creation instead, from
the client's detected "±HH:MM" offset.

- Carry browser_tz on the three account-creating auth requests (TelegramLoginRequest,
  GuestLoginRequest, EmailRequestRequest — the email account is provisioned at the
  code-request step, not at login) through the fbs envelope (+ Go/TS codegen), the
  gateway transcode + backend client, and the backend auth handlers into
  ProvisionTelegram / ProvisionGuest / ProvisionEmail.
- create() now writes time_zone explicitly: the validated detected offset, or 'UTC'
  (equal to the column default) when absent or malformed — deterministic, never guessed.
  The column is already NOT NULL DEFAULT 'UTC', so no migration is needed and existing
  accounts keep 'UTC'. An existing account is never overwritten on re-login.
- A detected zero offset is stored as "+00:00" (the zone is known and equals UTC),
  distinct from the "UTC" default that means "unknown" — which the feedback console's
  three-zone Filed display already reflects.
- Guard the guest handler against an empty payload (the bootstrap historically carried
  none) so it degrades to no-seed rather than panicking in GetRootAs*.
- Tests: zone seeding across Telegram/guest/email plus the "+00:00"/malformed/empty
  cases and the not-overwrite rule; codec round-trip for the three auth encoders.
  ARCHITECTURE + FUNCTIONAL(+ru) updated.
2026-06-22 18:43:24 +02:00
Ilia Denisov 004aca4e97 feat(feedback): capture the browser UTC offset; Filed time in three zones
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
The account time zone defaults to UTC until a player saves a profile, so a
report's Filed time could only render in UTC even for a player clearly in
another zone. Capture the client's detected "±HH:MM" offset (browser_tz) with
each submission and show the Filed time in three zones in the operator console
— UTC, the browser offset detected at submit, and the sender's saved profile
zone — each shown "N/A" when not known, so the operator can tell what is
certainly known from what is merely defaulted.

- Thread browser_tz through the fbs envelope (+ Go/TS codegen), the gateway
  transcode + backend client, and the backend feedback service/store; add the
  column via migration 00004 (additive, image-rollback-safe).
- Fix fmtTimeIn to resolve "±HH:MM" offsets via account.ResolveZone;
  time.LoadLocation alone silently fell back to UTC for offset zones, which is
  the second reason a "+02:00" sender showed only UTC.
- Update ARCHITECTURE/FUNCTIONAL(+ru) docs and the feedback integration test.
2026-06-22 18:05:39 +02:00
Ilia Denisov b78ce42922 feat(feedback): capture the app version; show version + local Filed time
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Each feedback submission now carries the client app version (__APP_VERSION__),
snapshotted like the interface language: FlatBuffers FeedbackSubmitRequest gains
a version field → gateway transcode → backend, persisted in a new nullable
feedback_messages.app_version column (migration 00003, additive so an image
rollback stays DB-safe). The operator console detail shows the app version and
renders the Filed time in UTC plus the sender's time zone (fmtTimeIn).

Touches: fbs schema + regenerated Go/TS codegen, codec + transport (the client
attaches its build), gateway transcode + backendclient, feedback store/service,
admin view + template, docs (ARCHITECTURE §15, FUNCTIONAL + _ru). Verified:
feedback integration tests (migration + version round-trip), codec round-trip,
check/unit/build green.
2026-06-22 17:02:05 +02:00
developer 08c2c5f660 Merge pull request 'fix(i18n): banner/push language follows the device's saved choice' (#113) from feature/banner-language-sync 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 54s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / deploy (push) Successful in 1m19s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 14:26:38 +00:00
developer 06cc4c1edc Merge pull request 'feat(ads): seed the house banner with the curated tip set' (#112) from feature/ad-tips-default-banner into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m20s
2026-06-22 14:26:29 +00:00
Ilia Denisov 90f0424de2 fix(i18n): reconcile preferred_language with the device's saved language
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 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The UI language follows the device (the local choice / system guess) and is
deliberately not overridden from the account, but the advertising banner and
out-of-app push routing are resolved server-side from preferred_language. A
saved device choice the account had not recorded — picked while a guest, or
differing from the Telegram system-language seed — left the banner (and pushes)
in the wrong language until a Settings change rewrote preferred_language.

On profile load (adoptSession and the in-place link path) push the saved local
choice to the account when it differs (new pure helper languageNeedsServerSync;
no-op for guests and when already equal), so the banner and pushes match the
visible UI from the first open. Unit-tested.
2026-06-22 16:16:33 +02:00
Ilia Denisov c36543e54e feat(ads): seed the house banner with the curated tip set
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Migration 00002 replaces the default (house) campaign's single seed tip with
the 47 curated, language-agnostic Scrabble tips — one bilingual ad_messages row
each (body_en + body_ru), which the client round-robins per ARCHITECTURE §ads.

Data-only: the ad_messages schema is unchanged, so a backend image rollback
stays DB-safe. Down restores the original single seed tip. Verified up/down
against a throwaway Postgres (47 rows; apostrophes escaped).
2026-06-22 15:44:49 +02: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 be1627936f Merge pull request 'chore(deploy): pin dictionary seed to v1.3.0' (#110) from feature/pin-dict-v130 into development
CI / changes (push) Successful in 2s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 54s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m19s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 13:06:26 +00:00
Ilia Denisov 1ba52dd0b4 refactor(deploy): make DICT_VERSION a required build arg (single-sourced)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m28s
Drop the literal version default from the build files (backend Dockerfile both
stages, loadtest Dockerfile, the compose build-arg) so the release tag is not
duplicated as a stale-prone default a newcomer can't tell from the real source.

DICT_VERSION is now required: compose uses ${DICT_VERSION:?…} and the Dockerfiles
have no ARG default, so a missing value fails loudly instead of baking a stale tag.
The tag lives only in its genuine sources — ci.yaml env (CI tests), the Gitea
TEST_/PROD_DICT_VERSION variables (deploy seed) and deploy/.env.example (local).
Adds a "Bumping the dictionary version" section to deploy/README and fixes the bare
docker-build examples (CLAUDE.md, README.md, loadtest/README) to pass --build-arg.
2026-06-22 15:03:07 +02:00
Ilia Denisov bb0e3e17e5 chore(deploy): pin dictionary seed to v1.3.0
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
Bump DICT_VERSION v1.2.1 -> v1.3.0 across the seed surface: .env.example, the
compose build-arg default, both backend Dockerfile stages, the loadtest
Dockerfile, the CI dawg-download version, and the deploy/backend docs.

v1.3.0 drops the abbreviation class from the Russian word list (scrabble-dictionary
#6). This only seeds a FRESH volume; a live contour/prod volume is unaffected (the
.seed_version marker wins — seed-drift guard) and moves to v1.3.0 through the admin
console (ARCHITECTURE §5). Per-contour deploy still overrides via TEST_/PROD_DICT_VERSION.
2026-06-22 14:45:52 +02:00
developer 1ef2bde395 Merge pull request 'feat(ui): Erudit blank tiles carry the star (✻) mark' (#109) from feature/erudit-blank-star into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
2026-06-22 11:06:22 +00:00
Ilia Denisov 62f66735a3 fix(ui): nudge the rack blank star up a pixel
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 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
By eye the centred star still read a hair low; subtract 1px from its top
offset (top: calc(0.5% - 1px)).
2026-06-22 12:55:06 +02:00
Ilia Denisov 81680a1d5e fix(ui): raise the rack blank star to centre on the letters
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 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Top-aligning it still read a touch low; move the empty-blank star up
(top 8% -> 0.5%) so its ink centres against the rack letters' block,
matching the board tile's centred mark. Size unchanged.
2026-06-22 12:22:48 +02:00
Ilia Denisov a393561d79 fix(ui): align the Erudit blank star with neighbouring tiles
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
Rack: the empty-blank star is now top-anchored level with the letters and a
touch larger (was centred, sitting low). Board and Stats best-move tiles: the
placed-blank star's ink is centred on the value digits' line (was slightly
high). CSS-only nudges; pixel offsets measured against the rendered glyphs.
2026-06-22 12:11:35 +02:00
Ilia Denisov e3e4cedc77 feat(ui): Erudit blank tiles carry the star (✻) mark
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
The Erudit variant's blank is the "звёздочка", so render it with a star.
An empty rack blank (and its drag ghost) shows ✻ centred; a placed blank
keeps its designated letter and carries ✻ where the (absent) point value
sits — on the board and in the Stats best-move tiles. The Scrabble variants
are unchanged. Gated by usesStarBlank() in lib/variants.ts.
2026-06-22 11:52:00 +02: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 91de26d80b Merge pull request 'Finalize docs to production + lobby/new-game UI tweaks + Telegram name fallback' (#107) from feature/finalize-docs into development
CI / unit (push) Successful in 10s
CI / changes (push) Successful in 2s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / unit (pull_request) Successful in 11s
CI / deploy (push) Successful in 1m22s
CI / changes (pull_request) Successful in 2s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 07:15:38 +00:00
Ilia Denisov 6b6362a629 fix(account): Telegram display-name falls back to the @username verbatim
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
When the Telegram first name yields no usable letters, fall back to the @username
taken whole (trimmed + length-capped, never character-stripped like the real name)
rather than a sanitized form; the generated placeholder is reached only when no
username is set. Precedence: real name -> @username (verbatim) -> placeholder.
2026-06-22 09:11:36 +02:00
Ilia Denisov 8a06fbc3c7 feat(ui): lobby invitation card redesign + new-game tweaks
- Lobby friend-invitation card: icon-only checkmark/cross actions stacked in a
  min-width right column; the middle column (From <name> + flag + variant rules,
  like New Game) grows and wraps. The cross now opens a decline-confirmation modal
  (mirroring the in-game resign confirm) instead of declining on first tap.
- New Game with a friend: a lone offered variant is pre-selected and its picker
  disabled (nothing else to choose); relabel 'Тип игры' -> 'Вариант' and
  'Подсказок на игрока' -> 'Подсказки'.
- Quick game: pin the Start button to the bottom of the screen, mirroring the
  friend-game Send-invitation button.
2026-06-22 09:11:36 +02:00
Ilia Denisov 48b06f4594 docs: finalize documentation to the production state
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
The project is live in production, so the staged-development scaffolding is removed.

- Delete the staged trackers PLAN.md and PRERELEASE.md.
- Rewrite CLAUDE.md: drop the per-stage workflow; codify the ongoing development
  principles (How we work) and the production model (Branching, CI & production):
  manual prod-deploy / prod-rollback, semver release tags, Ansible provisioning,
  expand-contract migrations.
- De-stage the living docs (README, ARCHITECTURE, TESTING, deploy/ansible, loadtest,
  platform/telegram READMEs) and the docker-compose tuning comments: drop the
  Stage N / R1-R7 / pre-release labels, keep every number and rationale, and fix the
  now-dangling PLAN.md / PRERELEASE.md references to describe the current state.
- Reword stale 'later stage' Go doc comments for subsystems that have shipped.
2026-06-22 08:33:30 +02:00
372 changed files with 25511 additions and 3712 deletions
+89
View File
@@ -0,0 +1,89 @@
---
name: deploy-check
description: "Use before any deploy-touching change to this repo — phrases like '/deploy-check', 'is this prod-safe', 'before we deploy', 'deploy safety review', 'проверь перед деплоем', 'это безопасно для прода'. Runs a pre-deploy checklist of this project's hard-won runtime constraints against the current diff, so the crash classes that have bitten live environments get caught before shipping instead of after."
---
# Pre-deploy runtime-constraint check
Triggered before shipping anything that touches the deploy contour (Dockerfiles,
`deploy/`, Caddyfile, compose, migrations, boot guards, the Telegram side-service,
edge config). The worst frictions in this repo were never logic bugs — they were
**environment mismatches that crashed a live env and forced a redesign**. Run this
list against the diff first; turn crash-and-redesign into a single pass.
This checklist is a prompt, **not** the source of truth. The canonical detail
lives in `deploy/README.md`, `docs/ARCHITECTURE.md`, `docs/EDGE_HTTP3.md`, and the
agent memory files referenced below — read them when an item is in play, and add a
new class here when a new incident teaches one.
## How to run it
1. `git diff <base>...HEAD --stat` to see what the change actually touches.
2. For every risk class below that the diff touches, perform the **Check** and
report PASS / FAIL with the exact file:line to fix. Skip classes the diff does
not touch — say which you skipped and why.
3. Remember: **deploy-job green ≠ healthy**. CI's deploy probe has historically
passed with a dead backend (it only checked static landing+gateway). Verify the
real feature live (`/readyz`, the actual flow) after deploy, not just the green.
## Risk classes
### 1. Container user — distroless nonroot UID 65532
- **Bit us:** TLS keys `chmod 600` for the host owner crash-looped gateway + bot at
boot with "permission denied" — service images run UID 65532.
- **Check:** any new/changed mounted secret, key, or config file must be readable by
UID 65532 (`0644`, not `0600`). Scan the diff for file modes, `chmod`, and new
volume mounts. (memory: `distroless-nonroot-mounted-secrets`)
### 2. Caddy header pipeline ordering
- **Bit us:** `header_up delete` after `set` nulled the value (the honeypot tag went
empty); it passed CI and only showed up live.
- **Check:** in any Caddyfile change, verify the `set` / `delete` / `header_up`
ordering for every affected route, and test the tripwire/route on the live
contour, not just CI.
### 3. Edge Alt-Svc / HTTP3
- **Bit us:** edge advertised `Alt-Svc: h3` while UDP/443 was never exposed
(docker tcp-only + ufw tcp-only); clients cached it 30 days and stalled on dead
QUIC before falling back to h2 — Mini App "hangs on load".
- **Check:** any edge/caddy change keeps `Alt-Svc: clear` (or only advertises h3 if
UDP/443 is genuinely exposed). (memory: `tg-app-load-stall-dead-http3-altsvc`,
`docs/EDGE_HTTP3.md`)
### 4. Prod caddy config recreate
- **Bit us:** prod rolling deploy did **not** recreate caddy on a config-only change
(pinned `caddy:2-alpine` + `admin off`), so a new Caddyfile deployed GREEN but
stayed inert until a manual `docker restart`.
- **Check:** a config-only edge change must `--force-recreate` caddy in
`prod-deploy.sh` `roll()`; never trust deploy-green for edge config.
(memory: `prod-deploy-caddy-config-recreate`)
### 5. DICT_VERSION / dictionary boot
- **Bit us:** an early `DICT_VERSION` refuse-boot guard was wrong and crashed the
live env when bumped on a seeded volume; it had to be redesigned to "marker-wins".
- **Check:** any change touching `DICT_VERSION`, dict load, or the boot guard must
keep marker-wins semantics and survive a seeded volume **and** an image rollback.
`DICT_VERSION` is a required build-arg (no default), single-sourced. A new dict
goes live via the admin console upload, not a redeploy. (memory:
`dict-version-deploy-verify`, `contour-schema-change-wipe`)
### 6. Migrations — expand-contract + rollback safety
- **Bit us / risk:** a non-backward-compatible migration breaks image rollback (DB
ahead of rolled-back code).
- **Check:** migrations must be **expand-contract** (backward-compatible). A schema
change adds the maintenance window + a consistent `pg_dump` in prod-deploy. On the
**test contour**, a schema/wire-label change needs `DROP SCHEMA backend CASCADE` +
backend restart (new code vs old persisted DB), else the contour breaks. (memory:
`contour-schema-change-wipe`)
### 7. Telegram permission model
- **Bit us:** permissions are an **AND-intersection** — default-allow with explicit
denies, not default-deny; inverting it broke access.
- **Check:** any change to the Telegram permission / relay logic preserves the
AND-intersection default-allow shape. (memory: `telegram-forum-relay-gotchas`)
## Output
A short PASS/FAIL table over the classes the diff touches, each FAIL with the exact
file:line and the fix. If every touched class passes, say so plainly and name the
post-deploy live check to run (not just "CI green").
+175
View File
@@ -0,0 +1,175 @@
# VK Mini App / VK Games — integration reference
Captured research + our implementation map, so a future session does not need to re-fetch
the VK docs. Authoritative external source: <https://dev.vk.com/> (the `dev.vk.com` portal
does not render via plain HTTP fetch; the facts below were cross-checked against the VKCOM
reference repos cited at the end and verified against our own Go implementation).
A VK **game** is technically a **VK Mini App**: an HTML5 SPA VK loads in an **iframe inside
vk.com** (desktop + mobile web) and in a **WebView** inside the VK mobile apps (iOS/Android).
We serve our existing SPA under a dedicated `/vk/` path, mirroring the Telegram `/telegram/`
entry — the single-origin, path-routed model.
## 1. Embedding model
- VK loads the app at the **Web iframe URL** configured in the app settings (HTTPS + valid
cert required), appending the signed launch parameters as the **URL query string**.
- An optional separate **Mobile iframe URL** is used by the VK mobile apps (we use the same).
- No special `X-Frame-Options` / CSP `frame-ancestors` is required from us — VK frames the
configured origin. (Our edge sets **no** framing headers today, so VK works as-is; see the
clickjacking note in §Security.)
- URL must match the settings exactly (scheme, host, no stray `www`/whitespace).
## 2. Launch parameters (URL query)
VK appends these to the iframe `src`. The `vk_*` set is what the signature covers.
| Param | Meaning |
| --- | --- |
| `vk_user_id` | signed-in VK user numeric id — **the identity** |
| `vk_app_id` | our registered app id |
| `vk_is_app_user` | 0/1 — user authorized/installed the app |
| `vk_are_notifications_enabled` | 0/1 |
| `vk_language` | 2-letter UI language (`ru`, `en`, …) |
| `vk_platform` | `mobile_iphone` \| `mobile_android` \| `mobile_web` \| `desktop_web` \| … |
| `vk_ts` | unix seconds when VK generated the params |
| `vk_ref` | where the app was opened from (`catalog`, `feed`, …) |
| `vk_access_token_settings` | comma-separated granted scopes (often empty) |
| `vk_group_id`, `vk_viewer_group_role`, `vk_is_favorite`, `vk_client` | optional/contextual |
| `sign` | **the signature** (see §3) — NOT part of the signed set |
Always present: `vk_user_id`, `vk_app_id`, `vk_platform`, `vk_ts`, `sign`.
The user's **name is NOT in the launch params** (only `vk_user_id`). Read it client-side via
`VKWebAppGetUserInfo` (see §4) — unsigned, so treat it as a cosmetic display seed only.
## 3. Signature verification (`sign`) — CONFIRMED base64url, not hex
Algorithm (verified against our `gateway/internal/vkauth` + an independent Python reference):
1. Collect the query params whose key starts with `vk_` (exclude `sign`).
2. Sort by key (alphabetical).
3. Serialize as a URL-encoded query string `k=v&k=v…` (Go `url.Values.Encode()` matches VK's
reference serialization for the constrained launch-param charset).
4. `HMAC-SHA256(serialized, secret)` where `secret` = the app's **«Защищённый ключ»**
(protected / secure key, a.k.a. client_secret) from the app settings.
5. **base64url, no padding** (`+``-`, `/``_`, strip `=`).
6. Constant-time compare against `sign`.
VK launch params have **no built-in expiry** (unlike Telegram's `auth_date`). We do NOT enforce
freshness — the minted server session is the short-lived credential; a replay only
re-authenticates the same `vk_user_id`.
Verified against the official doc <https://dev.vk.com/ru/mini-apps/development/launch-params-sign>
(prose + PHP example: base64url = `strtr('+/','-_')` + `rtrim('=')`) and reproduced identically by
independent Node `crypto` + Python references. **Doc-example caveat**: that page shows secret
`wvl68m4dR1UpLrVRli` → sign `exTIBP…`, but the secret is a **placeholder** — recomputing with it does
NOT yield the shown sign (it was made with the real, unshown key). Don't chase the mismatch; our
`vkauth.Verify` is correct (`gateway/internal/vkauth/vkauth_test.go` carries cross-checked vectors,
incl. the `%2C` comma case for `vk_access_token_settings`).
## 4. VK Bridge (client SDK)
`@vkontakte/vk-bridge` (npm, v3.x; bundled — `default` export `bridge`). Methods we use / may use:
- `VKWebAppInit`**required**: tells VK the Mini App loaded (dismisses VK's loading cover).
- `VKWebAppGetUserInfo``{ id, first_name, last_name, photo_200, … }`; no extra scope needed.
- `VKWebAppGetLaunchParams` — parsed `vk_*` **without** `sign` (so NOT usable for our server
verification — read `window.location.search` instead, which carries `sign`).
- `VKWebAppGetAuthToken` — OAuth access token for VK API calls (only if we ever call VK API).
- `VKWebAppShare` — native share dialog (the friend-code invite uses it; `navigator.share` is absent
in the desktop VK iframe). **Used.**
- `VKWebAppCopyText` — clipboard copy that works inside the VK iframe, where `navigator.clipboard` is
blocked. **Used** as the copy-code / copy-link path.
- `VKWebAppUpdateConfig` (subscribe) — light/dark scheme; the app follows it while the theme pref is
"auto" (the VK webview's prefers-color-scheme does not track it). **Used.**
- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile); not used.
- `VKWebAppUpdateInsets` (+ `VKWebAppUpdateConfig`) — device safe-area insets; the app **max'es** them
with CSS `env(safe-area-inset-*)` (viewport-fit=cover) so the bottom home bar is cleared. The bridge
value is needed on Android, where the VK webview exposes no `env()` inset. **Used.**
The bridge talks to the embedding VK client over postMessage; it is NOT an external fetch, so
it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import
it **lazily** so the pure URL helpers stay node-test-importable.
**Deep links — NOT possible on VK (confirmed on the contour).** The VK iframe receives ONLY the signed
`vk_*` launch params (+ `sign`); VK strips any custom data from the app link. The documented
`vk.com/app<id>#<payload>` form is eaten by the vk.com SPA (which owns the URL hash), and a
`vk.com/app<id>?hash=<payload>` query is dropped (the diagnostic showed `rawSearch` with only `vk_*`
and an empty `hash`). So the friend-code invite link is just `vk.com/app<id>` (`vkShareLink`, app id
from `vk_app_id`); the recipient enters the **copied code by hand** (`VKWebAppCopyText` works). The
`vkStartParam` reader + the `bootVK` routing stay as a no-op today, ready if a post-moderation VK
channel (e.g. an invite API) ever delivers a payload.
## 5. Test mode (to verify before moderation)
1. App already registered (we have the App ID).
2. In the app settings (dev.vk.com / `vk.com/editapp?act=settings&app_id=<id>`):
- Category = **Игра** (Game).
- **Web iframe URL** = our public HTTPS `/vk/` (the test-contour origin for contour testing,
prod `https://erudit-game.ru/vk/` later). Mobile iframe URL = same.
- Copy the **«Защищённый ключ»** → set as `GATEWAY_VK_APP_SECRET` (Gitea `TEST_`/`PROD_` secret).
- Add own VK id to **testers**; open in test mode.
3. Test mode = visible only to admins/testers, no payments processed.
## 6. Auth / identity (our model)
- `vk_user_id` (from verified params) → backend identity `kind='vk'`, `external_id=vk_user_id`,
auto-confirmed (a platform identity). First contact seeds language from `vk_language` and the
display name from the client-supplied `VKWebAppGetUserInfo` name (placeholder if empty).
- No VK access token / VK API call needed for the launch+login MVP.
## 7. Payments / monetization
VK Pay / «голоса» (votes) are **optional**, not required to publish a free game. Not planned.
## 8. ToS / moderation (pre-publish, analyzed — no blocker for a free «Эрудит»)
- **Trademark**: "Scrabble" is trademarked. Our public brand is **«Эрудит»** (erudit-game.ru),
a generic Russian word-game name → fine. Ensure the VK-registered app name is «Эрудит»/word-game,
NOT "Scrabble". The repo name is internal and irrelevant to moderation.
- **Pre-publish requirements**: public **Privacy Policy** + **ToS** URLs (disclose collected data:
`vk_user_id`, language; mention VK), **age rating** (likely 6+/12+), icon, description.
- **Dictionary**: standard word lists; VK may expect offensive-word filtering — likely fine for a
dictionary game, flag if moderation asks.
- **In-game chat (UGC)**: we already have a moderated chat + support relay → covered.
- Moderation reviews after submission (commonly ~2472h); rejects on violence/hate/sexual/illegal
content or IP infringement — none apply.
## 9. Platforms
Desktop web (iframe), mobile web (iframe), VK iOS app (WKWebView), VK Android app (WebView). Bridge
methods behave per-platform; the app's own back chevron + app-shell document-pin cover navigation
without VK-specific code. Theme/viewport fine-tuning is best verified live in the real VK client
(not reproducible in Playwright — like the iOS gesture caveats).
## 10. Our implementation map (what to touch for VK)
- **Wire**: `pkg/fbs/scrabble.fbs``VKLoginRequest{ params, browser_tz, display_name }`
(regen: `make -C pkg fbs` + `pnpm -C ui codegen`).
- **Gateway**: `internal/vkauth/` (the §3 verify), `internal/transcode` op `auth.vk`
(registered via `WithVKAuth(secret)` option; `DomainCode``invalid_vk_params`),
`internal/backendclient` `VKAuth``POST /api/v1/internal/sessions/vk`,
config `GATEWAY_VK_APP_SECRET`, SPA mount `/vk/` in `internal/connectsrv/server.go`.
- **Backend**: `internal/account` `KindVK` + `ProvisionVK`/`vkSeed` + `confirmed` for platform
kinds; `internal/server/handlers_auth.go` `handleVKAuth` + route; migration
`00005_vk_identity.sql` (widen `identities_kind_chk` to include `'vk'`, expand-contract).
- **UI**: `src/lib/vk.ts` (`onVKPath`/`vkLaunchParams`/`insideVK`/`vkInit`/`vkUserName` plus `vkAppId`/
`vkStartParam`/`vkShare`/`vkCopyText`/`vkOnScheme`), `app.svelte.ts` `bootVK` (+ deep-link routing
and VK scheme→theme) + the `/vk/` dispatch branch + shared `retryMiniAppBoot`, `codec.ts`
`encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`, `deeplink.ts` `vkShareLink`,
`Friends.svelte` (VK share/copy), `app.css` `--tg-safe-*` defaulting to `env(safe-area-inset-*)`.
- **Edge/deploy**: `deploy/caddy/Caddyfile` `/vk` path; `GATEWAY_VK_APP_SECRET` in
`docker-compose.yml` + `.env.example` + `ci.yaml` (`TEST_…` secret) + `prod-deploy.yaml`
(`PROD_…` secret, deploy-main).
- **Deferred**: payments (VK Pay / votes), native (Capacitor) VK, account-linking a vk identity to an
existing account, VK push. (Done after the launch+auth MVP — Group B: native share + clipboard via
the bridge, the friend-code deep link, the auto-theme follow, and the home-bar safe area.)
## Sources
- VKCOM/vk-bridge — <https://github.com/VKCOM/vk-bridge>
- VKCOM/vk-apps-launch-params (canonical signature examples) — <https://github.com/VKCOM/vk-apps-launch-params>
- kravetsone/vk-launch-params — <https://github.com/kravetsone/vk-launch-params>
- SevereCloud/vksdk `vkapps.ParamsVerify` (Go reference) — <https://pkg.go.dev/github.com/SevereCloud/vksdk/v2/vkapps>
- VK Mini Apps API — <https://github.com/VKCOM/vk-mini-apps-api>
+176 -16
View File
@@ -26,12 +26,12 @@ on:
push: push:
branches: [development] branches: [development]
# The dictionary release the test suite validates against — the current # The dictionary release. One Gitea variable is the single source of truth: the
# scrabble-dictionary release. Centralised here so a release bump is one edit; the # test suite validates against it here (inherited by the unit/integration jobs) and
# unit/integration jobs inherit it. The deploy job overrides it per contour with # both contours' deploy jobs seed a fresh volume with the same value. A release bump
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md. # is one edit (the variable). See deploy/README.md.
env: env:
DICT_VERSION: v1.2.1 DICT_VERSION: ${{ vars.DICT_VERSION }}
jobs: jobs:
# changes detects which areas a PR/push touched, so the test jobs can skip when # changes detects which areas a PR/push touched, so the test jobs can skip when
@@ -73,6 +73,9 @@ jobs:
go=false; ui=false go=false; ui=false
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
if echo "$files" | grep -qE '^ui/'; then ui=true; fi if echo "$files" | grep -qE '^ui/'; then ui=true; fi
# The render sidecar bundles ui/src/lib, so its dir rides the ui lane (the
# deploy's compose build picks it up either way).
if echo "$files" | grep -qE '^renderer/'; then ui=true; fi
# A workflow or deploy change re-runs everything as a safety net. # A workflow or deploy change re-runs everything as a safety net.
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
else else
@@ -199,6 +202,14 @@ jobs:
- name: Bundle-size budget - name: Bundle-size budget
run: node scripts/bundle-size.mjs run: node scripts/bundle-size.mjs
# The render sidecar executes the shared ui/src/lib/gameimage.ts on skia-canvas;
# its smoke test guards the bundling + skia seam (docs/TESTING.md).
- name: Render sidecar test
working-directory: renderer
run: |
pnpm install --frozen-lockfile
pnpm test
- name: Install Playwright browsers - name: Install Playwright browsers
run: pnpm exec playwright install chromium webkit run: pnpm exec playwright install chromium webkit
timeout-minutes: 5 timeout-minutes: 5
@@ -207,11 +218,66 @@ jobs:
run: pnpm run test:e2e run: pnpm run test:e2e
timeout-minutes: 5 timeout-minutes: 5
# conformance proves the client's local move preview (the ported dawg reader +
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
# a Go step generates golden parity vectors from the release dictionaries, then the
# gated Vitest suite replays them. It spans both toolchains, so it runs whenever the
# Go engine side or the UI side changed.
conformance:
needs: changes
if: ${{ needs.changes.outputs.go == 'true' || needs.changes.outputs.ui == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
GOPRIVATE: gitea.iliadenisov.ru/*
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: Generate golden parity vectors
run: |
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Local-eval conformance (reader + validator vs the Go engine)
working-directory: ui
env:
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
DICT_GOLD_DIR: /tmp/dictgold
DICT_VALID_DIR: /tmp/validgold
run: pnpm exec vitest run src/lib/dict/
# gate is the single branch-protection required check. It always runs and passes # gate is the single branch-protection required check. It always runs and passes
# only when each upstream job succeeded or was skipped (a path-filtered no-op), # only when each upstream job succeeded or was skipped (a path-filtered no-op),
# failing the merge if any actually failed or was cancelled. # failing the merge if any actually failed or was cancelled.
gate: gate:
needs: [unit, integration, ui] needs: [unit, integration, ui, conformance]
if: always() if: always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
@@ -221,7 +287,7 @@ jobs:
- name: Aggregate required checks - name: Aggregate required checks
run: | run: |
fail= fail=
for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}"; do for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}" "conformance:${{ needs.conformance.result }}"; do
name="${r%%:*}"; res="${r#*:}" name="${r%%:*}"; res="${r#*:}"
echo "$name = $res" echo "$name = $res"
case "$res" in case "$res" in
@@ -261,12 +327,47 @@ jobs:
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }} TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }} TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
# leaves the VK auth path (auth.vk) disabled until the operator sets the 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: 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 }} GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }} CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }} # TELEGRAM_MINIAPP_URL, GRAFANA_ROOT_URL and VITE_VK_ID_REDIRECT_URL are derived
# from PUBLIC_BASE_URL in the run step below, not stored as their own variables.
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }} TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }} TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }} TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
# The promo button reuses the UI's Mini App link variable. # The promo button reuses the UI's Mini App link variable.
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
@@ -276,11 +377,16 @@ jobs:
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} # VK Mini App landing link + VK ID "Web" app id: one value each serves every
# Unset vars render empty -> the compose ":-" defaults apply. # 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_DB: ${{ vars.TEST_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }} POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
DICT_VERSION: ${{ vars.TEST_DICT_VERSION }} DICT_VERSION: ${{ vars.DICT_VERSION }}
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }} LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
run: | run: |
# Seed the config files to a stable host path. The runner checks out into # Seed the config files to a stable host path. The runner checks out into
@@ -291,8 +397,34 @@ jobs:
conf="$HOME/.scrabble-deploy" conf="$HOME/.scrabble-deploy"
rm -rf "$conf" rm -rf "$conf"
mkdir -p "$conf" mkdir -p "$conf"
cp -r caddy otelcol prometheus tempo grafana "$conf"/ cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
export SCRABBLE_CONFIG_DIR="$conf" export SCRABBLE_CONFIG_DIR="$conf"
# Maintenance page for the redeploy window, mirroring prod-deploy.sh so the SPA
# overlay is exercised on the test contour too (not only prod). Raised just before
# the recreate and lowered once caddy is back (below); the trap clears it if the
# step fails so the contour never sticks in maintenance (and the reseed above wipes a
# stale flag anyway). The caddy-routed probes run in the NEXT step, after it is lowered.
maint_flag="$conf/caddy/on"
trap 'rm -f "$maint_flag"' EXIT
# Derive the public URLs from the one canonical origin instead of storing each as
# its own variable (paths are structural SPA routes / the Caddy /_gm sub-path).
# Exported before build so the VK ID redirect is baked into the SPA.
base="${PUBLIC_BASE_URL%/}"
export TELEGRAM_MINIAPP_URL="$base/telegram/"
export GRAFANA_ROOT_URL="$base/_gm/grafana/"
export VITE_VK_ID_REDIRECT_URL="$base/app/"
# Grafana's SMTP from_address must be a BARE address (it rejects the "Name" <addr>
# form the backend go-mail accepts) and validates it even when SMTP is disabled — a
# bad value crash-loops Grafana. Split the display-format SERVICE From into a bare
# address + name for Grafana; the backend keeps the full form.
svc_from="${SMTP_RELAY_SERVICE_FROM:-}"
case "$svc_from" in
*"<"*">"*)
export GRAFANA_SMTP_FROM_ADDRESS="$(printf '%s' "$svc_from" | sed -E 's/.*<([^>]+)>.*/\1/')"
export GRAFANA_SMTP_FROM_NAME="$(printf '%s' "$svc_from" | sed -E 's/[[:space:]]*<[^>]*>.*$//; s/^"//; s/"$//')" ;;
*)
export GRAFANA_SMTP_FROM_ADDRESS="$svc_from" ;;
esac
# Bot-link mTLS material for the test contour: a private CA + gateway/bot # Bot-link mTLS material for the test contour: a private CA + gateway/bot
# leaves (CN=gateway, the service name the bot dials). Prod supplies these # leaves (CN=gateway, the service name the bot dials). Prod supplies these
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy # from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
@@ -305,12 +437,23 @@ jobs:
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod # bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
# main host omits both. Without the profile they would not start here. # main host omits both. Without the profile they would not start here.
docker compose --ansi never --profile telegram-local build --progress plain docker compose --ansi never --profile telegram-local build --progress plain
# Raise the maintenance page, THEN bring caddy onto the reseeded config mount so it
# actually carries the flag: the running caddy sits on the stale pre-reseed mount (the
# dir was rm'd + recreated — see the force-recreate note below), so a flag written to
# the new dir is invisible until caddy is recreated. With the fresh caddy up, an open
# SPA sees the 503 marker + overlay for the whole recreate window, not a bare reconnect.
: > "$maint_flag"
docker compose --ansi never up -d --force-recreate --no-deps caddy
docker compose --ansi never --profile telegram-local up -d --remove-orphans docker compose --ansi never --profile telegram-local up -d --remove-orphans
# The config-only services bind-mount the reseeded config dir. A plain `up -d` # The config-only services bind-mount the reseeded config dir. A plain `up -d`
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a # leaves them on the previous bind mount (the dir was rm'd + recreated), so a
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to # changed Grafana dashboard is ignored — force-recreate them to pick up the fresh
# pick up the fresh config. # config. (Caddy was already recreated above so it would carry the maintenance flag.)
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana docker compose --ansi never up -d --force-recreate --no-deps otelcol prometheus tempo grafana
# Lower the maintenance page: services are back. An open SPA's poll now gets through
# (once the gateway finishes booting) and reloads into the fresh client; the caddy
# probes in the next step see 200. The EXIT trap is a backstop if we failed earlier.
rm -f "$maint_flag"
- name: Probe the landing, gateway and backend - name: Probe the landing, gateway and backend
run: | run: |
@@ -336,6 +479,23 @@ jobs:
docker logs --tail 50 scrabble-backend || true docker logs --tail 50 scrabble-backend || true
exit 1 exit 1
- name: Probe the /dict edge route reaches the gateway
run: |
set -u
# The client fetches each game's dictionary blob at {edge}/dict/{variant}/{version}
# for the local move preview. If caddy does not route /dict to the gateway the request
# falls to the static landing and the client silently gets a non-dawg blob. Probed
# unauthenticated it must be the gateway's 401 (the route reaches the gateway), never a
# 404/200 from the landing catch-all.
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/dict/scrabble_en/v1 2>&1 || true)"
echo "$out" | grep -E "HTTP/" || true
if echo "$out" | grep -q " 401"; then
echo "ok: /dict reaches the gateway (401 unauthenticated)"
else
echo "FAIL: /dict did not reach the gateway (expected 401) — caddy route missing?"
exit 1
fi
- name: Probe the Telegram validator and bot liveness - name: Probe the Telegram validator and bot liveness
run: | run: |
set -u set -u
+60 -41
View File
@@ -40,11 +40,22 @@ jobs:
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
VITE_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 }} POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }} # `docker compose build` interpolates the WHOLE compose file, so every :?-guarded
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }} # runtime var must be present at build even though it is not a build-arg — incl. the
# backend's EXPORT_SIGN_KEY (added with the finished-game export after v1.7.0, which is
# why the first v1.8.0 build tripped on it). POSTGRES_PASSWORD/GM_BASICAUTH_HASH above
# are here for the same reason; DICT_VERSION + the derived Mini App URL cover the rest.
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# PUBLIC_BASE_URL drives the derived VK ID redirect (baked into the SPA) and the
# Mini App URL; both are computed in the build step, not stored variables.
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
DICT_VERSION: ${{ vars.DICT_VERSION }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -58,10 +69,15 @@ jobs:
working-directory: deploy working-directory: deploy
run: | run: |
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=. export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
# The four main-stack images via compose (reuses the build args, incl. VERSION); # 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. # the bot separately, since it is profiled out of the prod compose.
docker compose -f docker-compose.yml -f docker-compose.prod.yml build docker compose -f docker-compose.yml -f docker-compose.prod.yml build
docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator renderer
docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" .. docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" ..
docker push "$REGISTRY/scrabble-telegram-bot:$TAG" docker push "$REGISTRY/scrabble-telegram-bot:$TAG"
@@ -82,17 +98,45 @@ jobs:
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }} TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
GATEWAY_VK_APP_SECRET: ${{ secrets.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): 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_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }} PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }} PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }} GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }} CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }} LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }} DICT_VERSION: ${{ vars.DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }} POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }} POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }} # TELEGRAM_MINIAPP_URL and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in
# deploy/write-prod-env.sh, not stored variables.
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -120,24 +164,8 @@ jobs:
run: | run: |
umask 077 umask 077
mkdir -p stage/certs-main mkdir -p stage/certs-main
cat > stage/env.sh <<EOF # Shared with prod-rollback so the two paths render an identical runtime env.
export REGISTRY='$REGISTRY' APP_VERSION="$TAG" bash deploy/write-prod-env.sh stage/env.sh
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$TAG'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
@@ -148,7 +176,7 @@ jobs:
ssh_main 'mkdir -p /opt/scrabble/compose' ssh_main 'mkdir -p /opt/scrabble/compose'
tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \ tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \
| ssh_main 'tar -C /opt/scrabble/compose -xzf -' | ssh_main 'tar -C /opt/scrabble/compose -xzf -'
tar -C deploy -czf - caddy otelcol prometheus tempo grafana \ tar -C deploy -czf - caddy otelcol prometheus tempo grafana blackbox \
| ssh_main 'tar -C /opt/scrabble -xzf -' | ssh_main 'tar -C /opt/scrabble -xzf -'
tar -C stage -czf - certs-main \ tar -C stage -czf - certs-main \
| ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -' | ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
@@ -176,9 +204,11 @@ jobs:
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }} PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }} PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }} LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }} # PUBLIC_BASE_URL drives the derived Mini App URL in deploy/write-prod-bot-env.sh.
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }} TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }} TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }} TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
steps: steps:
@@ -192,19 +222,8 @@ jobs:
run: | run: |
umask 077 umask 077
mkdir -p stage/certs-bot mkdir -p stage/certs-bot
cat > stage/env.bot.sh <<EOF # Shared with prod-rollback so the two paths render an identical bot env.
export SCRABBLE_CONFIG_DIR='/opt/scrabble' BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TAG" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TAG'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
+30 -35
View File
@@ -51,13 +51,32 @@ jobs:
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }} PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }} PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }} GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }} CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }} LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }} DICT_VERSION: ${{ vars.DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }} POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }} POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }} PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
# Full runtime env — parity with prod-deploy's deploy-main so a rollback re-renders
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
SMTP_RELAY_FROM: ${{ vars.PROD_SMTP_RELAY_FROM }}
SMTP_RELAY_ADMIN_FROM: ${{ vars.PROD_SMTP_RELAY_ADMIN_FROM }}
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
SMTP_RELAY_SERVICE_FROM: ${{ vars.PROD_SMTP_RELAY_SERVICE_FROM }}
SERVICE_EMAIL: ${{ vars.PROD_SERVICE_EMAIL }}
GRAFANA_SMTP_PORT: ${{ vars.GRAFANA_SMTP_PORT }}
GF_SMTP_ENABLED: ${{ vars.PROD_GF_SMTP_ENABLED }}
INPUT_TARGET: ${{ inputs.target_version }} INPUT_TARGET: ${{ inputs.target_version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -89,24 +108,9 @@ jobs:
run: | run: |
umask 077 umask 077
mkdir -p stage/certs-main mkdir -p stage/certs-main
cat > stage/env.sh <<EOF # Same writer as prod-deploy's deploy-main -> the rollback re-renders the FULL
export REGISTRY='$REGISTRY' # runtime env (not a subset), so email / VK login / Grafana alerts survive it.
export SCRABBLE_CONFIG_DIR='/opt/scrabble' APP_VERSION="$TARGET" bash deploy/write-prod-env.sh stage/env.sh
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$TARGET'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
@@ -147,9 +151,11 @@ jobs:
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }} PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }} PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }} LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }} # PUBLIC_BASE_URL drives the derived Mini App URL; SUPPORT_CHAT_ID for parity with deploy.
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }} TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }} TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }} TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
steps: steps:
@@ -163,19 +169,8 @@ jobs:
run: | run: |
umask 077 umask 077
mkdir -p stage/certs-bot mkdir -p stage/certs-bot
cat > stage/env.bot.sh <<EOF # Same writer as prod-deploy's deploy-bot (parity; includes TELEGRAM_SUPPORT_CHAT_ID).
export SCRABBLE_CONFIG_DIR='/opt/scrabble' BOT_IMAGE="$REGISTRY/scrabble-telegram-bot:$TARGET" bash deploy/write-prod-bot-env.sh stage/env.bot.sh
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TARGET'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
+91 -90
View File
@@ -1,96 +1,97 @@
# scrabble-game — project guide # scrabble-game — project guide
Multiplatform Scrabble game. Read this first every session. The owner drives the Multiplatform Scrabble game, **in production** at `https://erudit-game.ru`. Read this
project **one stage per session** (tariff constraint), so the repository — not first every session. The repository — not conversation memory — is the source of
conversation memory — is the source of continuity. Keep it that way. continuity; keep it that way.
## Sources of truth (read before changing behaviour) ## Sources of truth (read before changing behaviour)
- [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport, security,
details to interview*. the decision record. Always describes the current state.
- [`PRERELEASE.md`](PRERELEASE.md) — pre-release hardening tracker (phases R1R7 - [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md) mirror)
before Stage 18); same per-phase *interview + bake-back* discipline as `PLAN.md`. — per-domain user stories. English authoritative.
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport, - [`docs/TESTING.md`](docs/TESTING.md) — test layers + the CI gate.
security, the decision record. Always describes current state.
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
mirror) — per-domain user stories. English authoritative.
- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the per-stage CI gate.
- [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md) — the `ui` visual/interaction design system. - [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md) — the `ui` visual/interaction design system.
- [`deploy/README.md`](deploy/README.md) — the deploy contour + the production
rollout / rollback runbook.
## Mandatory per-stage workflow ## How we work
**Start of a stage** - Inspect the relevant code path and the docs above before changing behaviour.
1. Read `PLAN.md` (the stage's scope + *open details*) and the relevant `docs/`. - **Interview the owner on every fork** — do not silently pick borderline decisions;
2. Analyse what the stage actually requires against the current code. offer options with brief pros/cons.
3. **Interview the owner** on every open detail and any fork not already fixed - Smallest correct diff. Prefer compact code; reuse before adding; do not add deps,
in the plan — do not silently pick borderline decisions. Offer options with seams or knobs until they are needed.
brief pros/cons. - **Update or add tests for every functional change**, at the layers
4. Only then implement, strictly within the stage's scope. `docs/TESTING.md` calls out.
- **Bake docs in the same PR**: update `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md`
**End of a stage** (+`_ru`), the affected service `README` and Go Doc comments alongside the change.
1. Bake every new agreement back into `PLAN.md`, `docs/ARCHITECTURE.md`, - Document added packages, types, funcs, consts and vars with Go Doc comments.
`docs/FUNCTIONAL.md` (+ `_ru`), the affected service `README`, and Go Doc
comments — in the **same** PR. Correct earlier stages' docs/code if a new
decision changes them.
2. Update the stage tracker; add a line under *Refinements logged during
implementation* for any plan deviation.
3. Get CI green, then mark the stage done.
(The `stage-implementation` skill encodes this same loop and can be invoked.)
## Conventions ## Conventions
- All code, comments, identifiers, commits, docs, filenames in **English**. - All code, comments, identifiers, commits, docs, filenames in **English**.
- Chat with the owner follows the user-level `~/.claude/CLAUDE.md` (Russian, - Chat with the owner follows the user-level `~/.claude/CLAUDE.md` (Russian, the
the agreed persona and translation rules). agreed persona and translation rules).
- Mirror every point edit of `docs/FUNCTIONAL.md` into `docs/FUNCTIONAL_ru.md` - Mirror every point edit of `docs/FUNCTIONAL.md` into `docs/FUNCTIONAL_ru.md` in the
in the same patch (translate only the touched paragraphs). same patch (translate only the touched paragraphs).
- Prefer compact code; do not add deps, seams or knobs until a stage needs them.
Reuse before adding. Document added packages/types/funcs with Go Doc comments.
- Update or add tests for every functional change.
## Branching & CI ## Branching, CI & production
- **Two long-lived branches** (Stage 16 onward): **`development`** is the - **Two long-lived branches**: **`development`** is the integration branch; **`master`**
integration branch; **`master`** is the production trunk. Cut `feature/*` is the production trunk. Cut `feature/*` from `development` and PR back into it;
branches **from `development`** and PR them back into it. (Stages 015 used promote `development → master` via PR when ready to release. Both branches require
`master` as the trunk with `feature/* → master`; the genesis Stage 0 commit is one approval + the `CI / gate` check.
on `master` by necessity.) - A commit to a `feature/*` branch triggers nothing. The single workflow
- A commit to a `feature/*` branch triggers **nothing**. The single workflow `.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`) on a
`.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`) PR into `development` or `master`, and the gated **`deploy`** job auto-rolls the
on a PR into `development` or `master`, and the gated **`deploy`** job auto-rolls **test contour** on a PR into — or a push to — `development`
the **test contour** on a PR into — or a push to — `development` (`docker compose up -d --build` on the runner host + landing/SPA/backend probes). A
(`docker compose up -d --build` on the runner host + a `GET /` probe). A PR into PR into `master` is test-only.
`master` is test-only. - **Production is live on two hosts** (main + the Telegram bot host) and deploys
- Merge `development → master` only when CI is green; the **prod** deploy is then a **only manually** (`workflow_dispatch`), never automatically:
**manual** workflow (Stage 18), never automatic. Secrets/variables are prefixed - **`.gitea/workflows/prod-deploy.yaml`** (`confirm=deploy`, from `master`) builds +
`TEST_` / `PROD_` per contour (Gitea 1.26 has no deployment environments). pushes the images to the registry, then SSH-deploys both hosts — rolling per
- After any push, watch the run to green before declaring a stage done — use the service in dependency order, health-gated, **auto-rollback to the previous tag**;
ready-made watcher, never an inline poll loop: a schema migration adds a maintenance window + a consistent `pg_dump`. Four visible
`python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL` jobs: build → deploy-main → deploy-bot → verify.
/ `$GITEA_TOKEN`; `gitea.iliadenisov.ru` is allow-listed in - **`.gitea/workflows/prod-rollback.yaml`** (`confirm=rollback`) re-deploys a prior
`.claude/settings.json`. Remote: `origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`. release (blank `target_version` = the previous deployed version) — image-only,
rolling, health-gated.
- **Releases are git tags `vX.Y.Z` on `master`**; the deploy stamps `git describe
--tags` into the image tag, every binary (`pkg/version` via `-ldflags` → the
`service.version` telemetry attribute) and the SPA About screen. Tag the release
before deploying.
- Hosts are provisioned idempotently by **`deploy/ansible/`**. Per-contour
secrets/variables use the `TEST_` / `PROD_` prefix (Gitea 1.26 has no deployment
environments). Migrations must be **expand-contract** (backward-compatible) so
image rollback stays DB-safe. Full runbook + variable list in `deploy/README.md`.
- After any push, merge or deploy, **watch the run to green** before declaring done —
use the ready-made watcher (run it in the background), never an inline poll loop:
`python3 ~/.claude/bin/gitea-ci-watch.py`. It reads `$GITEA_URL` / `$GITEA_TOKEN`;
`gitea.iliadenisov.ru` is allow-listed in `.claude/settings.json`. Remote:
`origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`.
## Stack ## Stack
Go 1.26.3, `go.work` monorepo, module paths `scrabble/<name>`. Dependencies are Go 1.26.3, `go.work` monorepo, module paths `scrabble/<name>`. Backend uses `gin` +
added **when first used** (incremental): backend uses `gin` + `zap` + `zap` + `pgx`/`go-jet`/`goose`/OTel. Client↔gateway is Connect-RPC + FlatBuffers
`pgx`/`go-jet`/`goose`/OTel (added in Stage 1). Client↔gateway is Connect-RPC + (h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC server-stream for live
FlatBuffers (h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC events. UI is pure HTML5/CSS on plain Svelte + Vite, packaged to native with
server-stream for live events. UI is pure HTML5/CSS on plain Svelte + Vite, Capacitor. No Redis.
packaged to native with Capacitor. Likely no Redis.
## Reused engine: `../scrabble-solver` (module `scrabble-solver`, Go 1.26.3) ## Reused engine: `../scrabble-solver` (module `scrabble-solver`, Go 1.26.3)
Embedded **in-process as a library** — there is no per-game container. Public Embedded **in-process as a library** (`replace scrabble-solver => ../scrabble-solver`
API to reuse (do not reimplement): in `go.work`; CI checks out the sibling from
`https://gitea.iliadenisov.ru/.../scrabble-solver.git`). There is no per-game
container. Public API to reuse (do not reimplement):
- `scrabble.NewSolver(rs, finder)``GenerateMoves(b, r, mode)` (ranked, - `scrabble.NewSolver(rs, finder)` → `GenerateMoves(b, r, mode)` (ranked, highest
highest score first), `ValidatePlay(b, dir, tiles)`, `ScorePlay(...)`; score first), `ValidatePlay(b, dir, tiles)`, `ScorePlay(...)`; `scrabble.Apply(b, m)`;
`scrabble.Apply(b, m)`; types `Move/Word/Placement/Direction/Mode` types `Move/Word/Placement/Direction/Mode`
(`scrabble-solver/scrabble/{solver,move,apply}.go`). (`scrabble-solver/scrabble/{solver,move,apply}.go`).
- `rules.English() / RussianScrabble() / Erudit()` - `rules.English() / RussianScrabble() / Erudit()` (`scrabble-solver/rules/rules.go`).
(`scrabble-solver/rules/rules.go`).
- `board.New / Parse / Clone / Transpose`; `rack.New / Add / Remove / Clone`; - `board.New / Parse / Clone / Transpose`; `rack.New / Add / Remove / Clone`;
`selfplay.NewBag / Draw / Len` (bag pattern). `selfplay.NewBag / Draw / Len` (bag pattern).
- Load committed dictionaries with `dawg.Load(path)` from - Load committed dictionaries with `dawg.Load(path)` from
@@ -99,20 +100,17 @@ API to reuse (do not reimplement):
Constraints: Constraints:
- Words/tiles are **alphabet-index bytes**, meaningful only with the matching - Words/tiles are **alphabet-index bytes**, meaningful only with the matching
`rules.Ruleset` (`Alphabet.Decode`); blank flag carried separately. **Decode `rules.Ruleset` (`Alphabet.Decode`); the blank flag is carried separately. **Decode
to real characters before persisting history** (history must be to real characters before persisting history** (history must be
dictionary-independent — see `docs/ARCHITECTURE.md` §9.1). dictionary-independent — see `docs/ARCHITECTURE.md` §9.1).
- The solver's `internal/*` is NOT importable from this sibling module. - The solver's `internal/*` is NOT importable from this sibling module.
- **GCG is test-only** in the solver (no public writer) — we ship our own. - **GCG is test-only** in the solver (no public writer) — we ship our own.
- Wiring: add `replace scrabble-solver => ../scrabble-solver` to `go.work` in - The solver uses published `github.com/iliadenisov/{alphabet,dafsa}` (no local replace).
**Stage 2** (when `internal/engine` first imports it), and make CI check out
the solver sibling (`https://gitea.iliadenisov.ru/.../scrabble-solver.git`).
It uses published `github.com/iliadenisov/{alphabet,dafsa}` (no local replace).
## Repository layout ## Repository layout
``` ```
go.work # use the existing modules; grows per stage go.work # the go.work monorepo
backend/ # module scrabble/backend backend/ # module scrabble/backend
cmd/backend/ # main: telemetry -> db+migrate -> cache -> server cmd/backend/ # main: telemetry -> db+migrate -> cache -> server
cmd/jetgen/ # dev tool: regenerate go-jet code (throwaway container) cmd/jetgen/ # dev tool: regenerate go-jet code (throwaway container)
@@ -123,12 +121,15 @@ backend/ # module scrabble/backend
internal/session/ # opaque tokens, sessions store, cache, service internal/session/ # opaque tokens, sessions store, cache, service
internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes
internal/inttest/ # //go:build integration Postgres-backed tests internal/inttest/ # //go:build integration Postgres-backed tests
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md gateway/ # module scrabble/gateway: Connect-RPC edge, embeds the SPA
gateway/ ui/ pkg/ # added by their stages ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
platform/telegram/ # Telegram side-service, two binaries (Stage 9; split in phase TX): cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link) pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls
loadtest/ # module scrabble/loadtest: the pre-release stress harness (R2) platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2); gateway/Dockerfile has the `landing` target (R3), platform/telegram/Dockerfile has `validator`+`bot` targets (TX) renderer/ # image-render sidecar (Node + skia-canvas): runs ui/src/lib/gameimage.ts server-side for the finished-game PNG export
deploy/ # docker-compose (per-service limits, R7) + caddy + landing + otelcol (OTLP + docker_stats per-container metrics) + prometheus/tempo/grafana + postgres_exporter loadtest/ # module scrabble/loadtest: the load/stress harness
docs/ .gitea/workflows/ CLAUDE.md README.md
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile renderer/Dockerfile # multi-stage distroless (renderer: node:22-slim + skia-canvas + fonts); gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh
``` ```
## Build & test ## Build & test
@@ -138,20 +139,20 @@ go build ./backend/... # per module ('./...' from the root won't span t
go vet ./backend/... go vet ./backend/...
gofmt -l . # must print nothing gofmt -l . # must print nothing
go test -count=1 ./backend/... go test -count=1 ./backend/...
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram validator + bot (Stage 9; split in TX) go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram validator + bot
go run ./backend/cmd/backend # /healthz, /readyz on :8080 go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+) cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
pnpm start # UI mock mode: lobby -> game, no backend pnpm start # UI mock mode: lobby -> game, no backend
cd renderer && pnpm install && pnpm test # image-render sidecar (bundles ui/src/lib/gameimage.ts, skia smoke)
docker build -f backend/Dockerfile -t scrabble-backend . # images (Stage 16); gateway embeds the SPA docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway . docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing (R3) docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing
docker compose -f deploy/docker-compose.yml config # validate the full contour docker compose -f deploy/docker-compose.yml config # validate the full contour
``` ```
The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job of
of the single `.gitea/workflows/ci.yaml` (Stage 16 folded the former go-unit / the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
integration / ui-test workflows into it). Committed edge codegen under `ui/src/gen/`
(regenerate with `pnpm codegen`); pnpm build-script approval lives in (regenerate with `pnpm codegen`); pnpm build-script approval lives in
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`). `ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
-1613
View File
File diff suppressed because it is too large Load Diff
-632
View File
@@ -1,632 +0,0 @@
# Pre-release plan — hardening before Stage 18
Living tracker for the pre-release hardening pass that runs **before Stage 18** (the
prod cutover). Same discipline as [`PLAN.md`](PLAN.md): one phase per session,
**interview the owner on the open details** at the start of each phase, bake every
decision back into `PLAN.md` / `docs/` / the affected `README`s / Go Doc comments in
the **same** PR, get CI green, then mark the phase done. Phases run as
`feature/* → development` PRs (the Stage 16 branch model); the owner approves+merges.
**Why now:** the system is feature-complete through Stage 17 and the test contour is
green, but there is **no prod data yet** — schema, wire labels and the dictionary
layout can still change for free. These phases spend that one-time freedom and harden
the edge before prod. Each phase maps back to the owner's raw pre-release TODO list
(numbers in the tracker).
## Phase tracker
| # | Phase | Raw TODOs | Status |
|---|-------|-----------|--------|
| R1 | Schema & naming reset | 1 + 10 | **done** |
| R2 | Stress harness + contour observability + early run | 9a | **done** |
| R3 | Edge hardening | 2 + 8 + 3 | **done** |
| R4 | Push enrichment + kill the last poll | 4 + 5 | **done** |
| R5 | Bundle slimming | 6 | **done** |
| R6 | Refactor + docs reconciliation + de-staging | 7 | **done** |
| R7 | Final stress run + tuning | 9b | **done** |
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** |
| MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** |
| MW2 | Single-word rule connectivity fix: the word must run along its own line through an existing tile (perpendicular-only contact no longer connects); single-tile direction picks the best legal word (engine v1.1.1) | owner ad-hoc | **done** |
| MW3 | Graceful replay degradation: a game whose journalled move became illegal under MW2 is closed as a draw (`end_reason='aborted'`) on open instead of erroring, with an impersonal organizer note in the history + GCG (migration `00002`) | owner ad-hoc | **done** |
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90180 s) | owner ad-hoc | **done** |
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
| AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** |
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
| GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** |
| CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** |
| BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists. Blocking a disguised-robot opponent is recorded per-game in a separate **`robot_blocks`** table (migration `00011`), keyed on game+seat with the seen name — never the shared robot account — so the matchmaker keeps giving robots; it shows in the blocked list and re-marks the in-game card | owner ad-hoc | **done** |
| FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) — not a seed — is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** |
| SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** |
| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** |
| TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs, an SSH deploy of both hosts together — is **built in Stage 18** (the two-host registry rollout; first cutover pending the `erudit-game.ru` DNS). | owner ad-hoc | **done** (code + test contour; prod wiring built — Stage 18) |
| AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **done** (code + test contour; ban on in prod via Stage 18 — machinery built, cutover pending DNS) |
| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat. The chat **allows sending by default** and the bot only restricts (Telegram intersects the chat default with the per-user permission, so a per-user grant cannot exceed a deny-by-default group): it **mutes** a member who is not registered or is admin-suspended or holding a new **`chat_muted`** role, and **un-mutes** an eligible one it had muted, for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), resolved once in the backend and reached two ways: the bot's `ResolveChatEligibility` on a `chat_member` event over the existing mTLS bot-link, and a backend `chat_access_changed` event → gateway → `ChatGate` command (emitted on block/unblock, a `chat_muted` change, a first registration, or a temporary-block expiry via a sweeper; idempotent). No schema change — `chat_muted` reuses `account_roles`. | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase)
- **R1 (TODO 1 + 10) is one cheap moment, now.** Squashing the 12 goose migrations is
safe precisely because there is no prod data and the contour DB is wiped. Folding the
new variant labels (`scrabble_ru`/`scrabble_en`/`erudit_ru`) into that single baseline
makes the rename need **no data migration and no back-compat mapping**. Today's labels
(`english`/`russian_scrabble`/`erudit`) are persisted in `games.variant`,
`game_invitations.variant`, in `pkg/fbs` and the UI — ~100 files, but a mechanical sweep
on a clean DB.
- **R4 (TODO 4 + 5): the app is already push-first.** Game state refreshes on
`your_turn`/`opponent_moved`, the lobby on `notify`, chat on `chat_message`. The **only**
genuine periodic server poll is `lobby.poll` (matchmaking, 2.5 s,
`ui/src/screens/NewGame.svelte`). What remains is killing that one poll **and** enriching
push events to carry payloads so the UI stops re-fetching after each signal.
- **R3 (TODO 2): identity forgery is already mitigated.** Identity is always derived from
the session (`Authorization: Bearer``X-User-ID`); the client cannot inject identity,
the backend re-validates resource ownership, Telegram initData is HMAC-checked. The real
gaps are a missing **request-body size limit** (cheap DoS) and **invisible rate-limit
rejections** (no log/metric/admin view — that is TODO 8). Static landing serving is **not**
covered by the gateway token bucket (it only guards `Execute`).
- **R6 (TODO 7) scale:** ~431 `Stage N` references across ~104 files (incl. the file name
`backend/internal/inttest/stage6_test.go`). Code is the source of truth; `docs/` describe
current state; `PLAN.md` keeps the decision history.
## Locked decisions (owner interview)
- **Stress test (TODO 9):** **early + final** runs. Driver = **edge protocol** (Connect/FB
through the gateway, moves generated by the solver) **plus a separate gateway-hammer**
saturation test. Pacing = **realistic (under limits) + saturation (ramp to the knee)**.
Resource metrics = **add cAdvisor + postgres_exporter to the contour** (today only
Go-runtime metrics exist). The harness stays in the repo for repeats.
- **Push (TODO 4 + 5):** **both** — kill `lobby.poll` (use the existing `match_found`, keep
poll as the ws-down fallback) **and** enrich push events with payloads.
- **Refactor (TODO 7):** **hygiene + structural changes by a reviewed list**
behaviour-preserving, test-gated, contentious items surfaced to the owner before applying.
- **Landing (TODO 3):** **separate static container** behind the project caddy
(`/` → landing, `/app/` + `/telegram/` → gateway); drop `landing.html` from the gateway
`go:embed`.
- **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag**
a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold,
**no auto-ban**.
- **Anti-abuse IP ban (AG, owner ad-hoc):** a honeypot was considered and rejected as a *DDoS*
defence — it detects/deceives but does not shed volumetric load, cannot cover the real
endpoints, and a tarpit backfires under flood; volumetric L3/L4 is an upstream/CDN concern,
out of scope. The effective layer is a **temporary IP ban** (fail2ban-style) that the honeypot
and honeytoken merely *feed*. This does **not** reverse the TODO-8 "no auto-ban": that decision
governs the **account** soft-flag (still never a gate); the IP ban is a separate, IP-keyed,
**prod-only** layer with an **operator unban** in the console. Decisions: banlist lives in the
existing `ratelimit` package (smallest surface); the decoy path list is a **single source of
truth in the caddy** (it tags requests with a header — the gateway keeps no second list);
bans are in-memory + single-instance (like `ratewatch`), auto-expiring, **plus** an admin
console view + manual unban over a bidirectional 30 s sync (operator control = owner's choice).
An active-bans Grafana **gauge** was trimmed (the console view + the `gateway_abuse_banned_total`
counter cover it) to keep the diff focused.
- **Open auto-match (owner ad-hoc):** a quick game **enters a real game at once and waits inside
it** (status `open`, the opponent seat empty); a second human searching the same variant+rule
joins it, or a robot fills it after a **90 s + random 090 s** wait, pushing the in-app
**opponent_joined** event. While open, the starter may move on their turn but resign, chat and
nudge are disabled, and the lobby + opponent card read "searching for opponent". Matchmaking is
now **DB-backed open games** — the in-memory pool, `lobby.poll` and `lobby.cancel` are gone. The
schema is edited in the baseline (no prod data); `game_players.account_id` is nullable for the
empty seat.
- **Telegram egress off-host (TX, owner ad-hoc):** the driver is **removing VPN/Telegram
traffic from the main host** (OPSEC / one fewer analysis vector), not only notification
resilience. Login is local HMAC, so it stays up regardless of the bot — confirmed in the
code and made structural by the split. **Unified topology in code** (validator + bot, the
bot dialing the gateway) in **both** contours, differing only in deployment; the test bot
keeps its VPN sidecar. Transport = a **reverse gRPC bidi stream, mTLS, bot-dials-gateway**
(no inbound/static IP on the bot), reusing the push-stream pattern; **webhook rejected**
(one URL per token, adds inbound + a static address). Delivery **at-most-once** (a dropped
nudge beats a duplicate). **One bot now**, seams (registry + `owns_updates` + command ids)
for N later. **Cert rotation** by a scheduled CI job from a long-lived CA. **Prod deploy by
SSH** (pull excluded), the bot rolled **together** with the main app (the bot-link protocol
kept back-compatible by one version as the non-atomic-two-host-deploy safety net). The bot
is monitored **from the gateway** (connection + ack metrics). The bot-host token-at-rest is
**accepted**.
## Phases
Each phase: read this tracker + the relevant `docs/`, **interview the owner on the open
details below**, implement within scope, then update the tracker + docs/code and get CI
green before marking it done.
### R1 — Schema & naming reset *(TODO 1 + 10)* — first
Squash `backend/internal/postgres/migrations/00001..00012` into one `00001_baseline.sql`
(method: `pg_dump --schema-only` from a fully-migrated DB → wrap as the goose baseline →
prove a fresh migrate yields a schema identical to the 12-migration chain via the
integration suite → delete the old files; keep goose). Bake the new variant labels into the
baseline. Propagate `scrabble_ru`/`scrabble_en`/`erudit_ru` through the backend
(`engine.Variant`/`ParseVariant`, `registry.dictFiles`, the CHECK values), the wire
(`pkg/fbs` `variant:string`, regenerate FB) and the UI (`lib/model.ts` union, `variants.ts`,
fixtures, premium/alphabet keys, tests); i18n display keys stay display-only. Tidy
`../scrabble-dictionary` to a single source→dawg build point and align the dawg artifact
names to the new labels (crosses into `../scrabble-solver`'s committed fixtures — keep them
byte-identical). After merge, **wipe the contour DB** (drop the volume) so it re-provisions
on the next deploy.
- Critical files: `backend/internal/postgres/migrations/`,
`backend/internal/engine/{engine,registry}.go`, `pkg/fbs/scrabble.fbs`,
`ui/src/lib/{model,variants}.ts`, `../scrabble-dictionary/{Makefile,cmd/builddict,…}`.
- Open details to interview: the exact dawg filename scheme; whether the dict-repo tidy is
one PR or split; how to script the contour DB wipe in the deploy.
### R2 — Stress harness + contour observability + early run *(TODO 9, part 1)*
Build the reusable load harness as a new `loadtest` module in `go.work` (reuses `pkg/fbs`,
`connect-go`, and `scrabble-solver` for legal-move generation): a seeder that inserts
**1000 guest + 10000 durable** accounts with pre-created sessions (token hashes) directly in
the DB and hands the plaintext tokens to the client; a driver that runs N virtual users,
each in 35 concurrent 24-player games, exercising submit-play / pass / exchange / nudge /
chat / check-word / draft-move / profile-save through the **edge protocol**, in
**realistic** (under rate limits) and **saturation** (ramp) modes; plus a separate
**gateway-hammer** that deliberately exceeds limits to verify the limiter holds and measure
its cost. Add **cAdvisor + postgres_exporter** to `deploy/docker-compose.yml` and a Grafana
resource dashboard. Run the **early pass** against the freshly-wiped contour; produce a
**trip report** (logic/concurrency bugs + a resource baseline) that feeds R3 and R6.
- Critical files: new `loadtest/`, `deploy/docker-compose.yml`, `deploy/observability/*`,
`docs/TESTING.md`.
- Open details: the scale ramp steps; the move-selection policy (a mid-ranked solver move
for realistic game progress); run duration; the pass/fail bar.
### R3 — Edge hardening *(TODO 2 + 8 + 3)*
Add a **request-body size cap** at the gateway h2c mux / `Execute` (e.g. ~1 MB). Add
**rate-limit observability**: a `gateway_rate_limited_total{class}` counter + a structured
log per rejection; an **aggregate** Grafana panel (request rate + rejection rate — spikes
visible without per-user label cardinality, honouring the Stage 12/17 discipline); an
**admin-console view** of recently throttled users/IPs (in-memory ring buffer, single-
instance, reset-on-restart, like the `active_users` gauge). Add the **conservative
auto-flag**: when a user is *sustained*-throttled past a tunable threshold, set a soft,
reversible `account.flagged_high_rate_at` marker (baked into the R1 baseline) surfaced in the
admin user list/detail — **no auto-ban**; the operator clears it. Split the **landing** into
its own static container (`deploy/` + a Caddyfile route `/` → landing) and drop
`landing.html` from the gateway `go:embed`.
- Critical files: `gateway/internal/connectsrv/server.go`, `gateway/internal/ratelimit/`,
`gateway/internal/connectsrv/metrics.go`, `backend/internal/adminconsole/`,
`deploy/caddy/Caddyfile`, `deploy/docker-compose.yml`, `gateway/internal/webui/`.
- Open details: the auto-flag threshold/window + whether the marker is persisted vs
in-memory; the landing image base (caddy vs nginx).
### R4 — Push enrichment + kill the last poll *(TODO 4 + 5)*
Replace `lobby.poll` with the existing `match_found` push (keep the poll as a ws-down
fallback). Enrich `your_turn`/`opponent_moved`/`notify` to carry the state payload so the UI
renders from the event without a follow-up `game.state` (removes the lobby↔game nav latency
the owner noticed). Wire-contract change: `pkg/fbs` event payloads → backend `notify` emit →
UI stream consumers (`ui/src/lib/app.svelte.ts`), with the per-game cache as the landing
spot; regenerate FB.
- Critical files: `pkg/fbs/scrabble.fbs`, `backend/internal/notify/events.go`,
`ui/src/lib/{app.svelte,transport}.ts`, `ui/src/screens/NewGame.svelte`.
- Open details: which events carry full vs delta payloads; the fallback-poll cadence when the
stream is down.
### R5 — Bundle slimming *(TODO 6)* — done
Analysed the bundle against the 100 KB-gzip budget; **no code slimming was warranted**, and the
budget metric was retargeted to measure the app correctly. The build already minifies +
tree-shakes; the dominant cost is the Connect/FlatBuffers transport runtime + generated bindings
+ the Svelte runtime (≈⅔ of `main`'s source is third-party/generated) — irreducible within scope.
**Lazy-loading was rejected**: `bundle-size.mjs` sums every emitted chunk, so code-splitting yields
no total-size win and adds request latency (+N gateway fetches on first navigation to a split
screen). i18n lazy-load was skipped (the catalogs are a sliver of a Svelte-runtime-dominated shared
chunk, and `en` must stay bundled as the `MessageKey` type source + fallback). Instead,
`bundle-size.mjs` now measures **per HTML entry**, with three independent gates on the natural chunk
boundaries — **app entry ≤ 100 KB, the Svelte+i18n shared chunk ≤ 30 KB, the landing's own chunk
≤ 5 KB** — since the app's real payload is its entry chunk plus the shared chunk (≈97 KB), while the
landing (≈24 KB) is reported separately and kept minimal. Same CLI + exit-code contract, so the CI
step is unchanged.
- Critical files: `ui/scripts/bundle-size.mjs`; no app code changed.
### R6 — Refactor + docs reconciliation + de-staging *(TODO 7)* — done
Behaviour-preserving only. Three separable, separately-committed passes: (a) mechanical
**de-staging** — remove `Stage N`/`TODO-N` references from code, comments and service
READMEs (rename `stage6_test.go`); (b) **docs↔code reconciliation** — reconcile
`docs/ARCHITECTURE.md` / `docs/FUNCTIONAL.md`(+`_ru`) against the code-as-truth, fixing drift
and Go Doc comments; (c) **structural changes by a reviewed list** — surface a list of
proposed optimizations / test-suite consolidations to the owner, apply only the approved,
behaviour-preserving, test-gated ones. The full suite + the final stress run (R7) are the
regression gate. Incorporates the early-run (R2) bug fixes not already shipped.
- Open details: the structural-changes list itself (owner-approved before applying); the test
consolidation targets.
### R7 — Final stress run + tuning *(TODO 9, part 2)* — done
Re-run the R2 harness against the final, refactored system on a clean contour; analyse
resource consumption across **all** components (gateway, backend, Postgres, the
metrics/observability stack, docker log volume) and agree the tuning (pool sizes, rate
limits, cache TTLs, container limits, GOMAXPROCS, log levels). Apply the agreed tuning; record
the methodology + results in the repo.
**Stage 18** (prod contour) then proceeds per [`PLAN.md`](PLAN.md).
## Sequencing rationale
`R1` first (cheapest now; everything builds on the final schema/naming and the stress test
must run against it). `R2` builds the harness and runs the **early** pass to surface bugs and
a resource baseline that feed `R3` and `R6`. `R3`/`R4`/`R5` harden and improve the system.
`R6` (de-stage + reconcile + structural) runs near the end so it sweeps settled code once and
benefits from all accumulated bug knowledge. `R7` validates the final system and tunes it.
Then Stage 18.
## Regression-safety discipline (cross-cutting)
- Every phase is a `feature/* → development` PR; CI (`unit` + `integration` + `ui` behind the
`CI / gate` check) must be green before the owner merges; watch the post-merge contour
deploy with `gitea-ci-watch.py`.
- `R6` structural changes are behaviour-preserving, test-gated, and split from the mechanical
sweeps; contentious items are owner-approved first.
- The two stress runs (`R2` early, `R7` final) are the system-level regression gate.
## Verification (per phase)
- `go build ./<module>/...`, `go vet`, `gofmt -l .` clean, `go test -count=1 ./<module>/...`;
UI: `pnpm check && pnpm test:unit && pnpm build`; the integration suite
(`-tags integration`) for DB/schema changes; `docker compose config` for deploy changes;
green CI on the PR + a healthy contour deploy.
- `R1`: prove the squashed baseline yields a schema identical to the 12-migration chain
(integration suite on a fresh DB) **before** deleting the old files.
- `R2`/`R7`: the harness runs end-to-end against the contour; the trip report lists concrete
defects + a resource profile from the Grafana cAdvisor/postgres_exporter panels.
## Refinements logged during implementation
- **R1** (interview + implementation):
- **Variant labels** `english`/`russian_scrabble`/`erudit`**`scrabble_en`/`scrabble_ru`/`erudit_ru`**
across the backend (`engine.Variant.String`/`ParseVariant`; the `games`/`game_invitations` `variant`
CHECK in the baseline; GCG `#lexicon` and the `variant` metric attribute both flow from `String`),
the wire (`pkg/fbs` `variant` is a `string` field — values change with **no FlatBuffers regen**) and
the UI (`model.ts` union, `variants.ts` records, `codec`/`premiums`/mocks/tests, the admin
`dictionary.gohtml`). **Kept:** the Go enum identifiers (`VariantEnglish`…, internal) and the i18n
display keys (`new.english`/`new.russian`/`new.erudit`, display-only). `complaints.variant` stays
free-text (no CHECK, as before).
- **dawg filenames kept descriptive** (`en_sowpods`/`ru_scrabble`/`ru_erudit`) — only the registry's
`Variant` key carries the rename, so `registry.go`, the published `scrabble-solver` fixtures and the
dictionary release artifact are untouched (decouples the three repos).
- **Migrations squashed** 12 → one hand-written `00001_baseline.sql`. Verified by a
`pg_dump --schema-only` diff (the chain vs the baseline are **identical** but for the two intended
variant-CHECK values) plus the green integration suite. **No data migration** (no production data).
- **Done (cross-repo + contour):** the **`scrabble-dictionary` tidy** merged (PR #2) and was re-cut as
the **byte-identical `v1.0.1`** release for clean provenance (the backend stays on `v1.0.0` — same
bytes, no rewire; the backend pulls a version-pinned release artifact, not master). Post-merge the
contour `backend` schema was wiped (`DROP SCHEMA backend CASCADE` + restart, not a volume drop) and
re-migrated to the baseline — verified the new variant CHECK (`scrabble_en/scrabble_ru/erudit_ru`),
`games`=0 and a clean boot.
- **R2** (interview + implementation):
- **Locked decisions:** game assembly via **invitations** (real path, no robots; not direct game-row
inserts); **moderate** ramp **50 → 200 → 500** at 10 min/step; **diagnostic** pass bar (no SLO gate);
run as a **one-shot container on `scrabble-internal`** in this PR.
- **Harness** = new `scrabble/loadtest` module (`use ./loadtest` + a `replace scrabble/gateway` for the
dot-free edge-proto import). It seeds 1000 guest + 10000 durable accounts + sessions **directly in
Postgres** (token hash mirrors `backend/internal/session`), drives players over the **edge protocol**,
generates **mid-ranked legal moves locally** with the embedded `scrabble-solver` by replaying
`game.history` (the edge carries no board — mirrors `engine.ReplayBoard` via the public API), and a
**gateway-hammer**. Compact CLI (`run` / `cleanup`), distroless Dockerfile (DAWGs baked), Go unit tests.
- **Adding the module broke the other images' builds** — backend/gateway/telegram Dockerfiles reduce the
workspace but still referenced `./loadtest` (not in their context); each now also
`-dropuse=./loadtest` (backend/telegram additionally `-dropreplace` the gateway replace). Caught by the
first deploy run; verified by building all four images.
- **Harness payload fixes found by the smoke pass:** the draft DTO's `rack_order` is a string (was sent
as `[]``bad_request`); the display-name validator forbids digits/colons, so the cleanup marker
became a letters-only `Zzloadtest` so `profile.update` resends the seeded name. `chat_not_your_turn` /
`nudge_own_turn` are **by-design** turn gates, correctly exercised.
- **Observability:** added **cAdvisor + postgres_exporter** + the **Scrabble — Resources** dashboard +
two Prometheus jobs. **Finding:** cAdvisor yields only the root cgroup on the contour host (separate
XFS `/var/lib/docker` breaks its layer-ID resolution — the existing galaxy deploy has the same limit),
so per-container CPU/RSS for the early pass was captured via `docker stats`. **R7:** adopt the otelcol
`docker_stats` receiver (already the contrib image) for per-container metrics in Grafana.
- **Early run (2026-06-09):** ramped clean to 500 players, no crash/deadlock, cleanup removed all 11000
accounts. 1.2 M edge calls, 48 870 plays, 2 798 games finished; the per-user limiter held under the
hammer (99.97 % rejected, p99 2 ms). **Top finding:** ~14 % `transport_error` on `game.state` at 500
players, under CPU saturation (backend/gateway/Postgres each ~1 core) and amplified by the harness's
single shared `http2.Transport`; the harness itself peaked at 86 % of a core on the same host, so the
figures are pessimistic. Full trip report in [`../loadtest/REPORT.md`](../loadtest/REPORT.md);
it feeds R3 (h2c `MaxConcurrentStreams`/timeouts, body-size cap), R6 and R7 (per-player transports,
separate hardware, pool/limit sizing).
- **CI:** `./loadtest/...` added to the path filter + vet/build/test; `go.work.sum` carries the new deps.
- **R3** (interview + implementation):
- **Locked decisions:** the flag column lands by **editing the R1 baseline** (+ a contour schema
wipe after merge — no migration chain accrues before prod); auto-flag defaults **1000 rejected /
10 min** (`BACKEND_HIGHRATE_FLAG_THRESHOLD`/`_WINDOW`, rolling window, set-once, operator clears,
no auto-ban); landing image = **caddy:2-alpine**; throttle data flows **gateway → backend** (a
30 s per-key summary POST to the new `/api/v1/internal/ratelimit/report`, the existing trusted
direction) with the episode window + flag rule in the backend (`internal/ratewatch`); rejection
logging = **Warn summary per key per window + Debug per rejection** — a deliberate deviation from
the phase's "structured log per rejection" (the R2 hammer would have logged ~522k lines in
minutes); all three R2-report tails included (explicit h2c sizing, the session-resolve failure
cause at Warn, reviving the admin limiter).
- **Body cap:** `GATEWAY_MAX_BODY_BYTES` (default 1 MiB) as both the Connect per-message read limit
and an `http.MaxBytesReader` wrap of the public mux; an oversized Execute is `resource_exhausted`.
- **Dead config found:** `AdminPerMinute`/`AdminBurst` were never wired — the gateway `/_gm` mount is
now 429-guarded per IP ahead of its Basic-Auth. The caddy-fronted contour path stays unlimited
(stock caddy has no limiter) — an accepted gap, recorded in `docs/ARCHITECTURE.md` §12.
- **Landing split:** a `landing` target in `gateway/Dockerfile` (the UI build stage is shared;
identical compose build args keep it one cached build); the gateway drops `landing.html` from the
embed and 308-redirects `/``/app/`; the contour caddy routes `/app/`, `/telegram/` and the
Connect path to the gateway and the catch-all to the landing container; the CI deploy probe now
checks both `/` (landing) and `/app/` (gateway).
- **Observability:** `gateway_rate_limited_total{class}` (user/public/email/admin, aggregate-only)
+ a rate-vs-rejections panel on the Edge/UX dashboard; the admin console gains the **Throttled**
page (the in-memory episode window, reset-on-restart like `active_users`, plus the flagged-account
queue) and the flag badge / clear action on the user list / card.
- The jet regen also restored the previously missing `game_drafts`/`game_hidden` generated models
(their tables were added after the last jetgen run; no behaviour change).
- **R4** (interview + implementation):
- **Locked decisions:** **delta-first**, not full snapshots — an event carries only the new move and
the UI applies it to its per-game cache, keyed on `move_count` (idempotent + gap-safe: a gap or the
actor's own move falls back to a `game.state` + `game.history` refetch). `match_found` /
`game_started` carry the recipient's **initial `StateView`** (instant lobby→game); the fallback
refetch stays the existing two calls (no merged endpoint); the matchmaking poll runs **only while
the stream is down** (2.5 s); **all** UI-state-changing events carry their payload (incl. lobby `notify`).
- **Enriched events** (`pkg/fbs` trailing fields — backward-compatible, no FB regen of *values*, only
the schema): `opponent_moved` (+`move`/`game`/`bag_len`), `your_turn` (+`move_count`), `match_found`
(+`state`), `game_over` (+`game`), `notify` (+`account`/`invitation`/`state`). The pre-R4
`opponent_moved` scalars (`seat`/`action`/`score`/`total`) stay for wire back-compat, now redundant
with `move`/`game` — slated for the R6 de-stage.
- **Encoding placement:** the `notify` package keeps ownership of the FlatBuffers encoding (a new
`encode.go` mirrors the gateway transcode but reads wire-agnostic `notify.*` input structs +
`engine.MoveRecord`); the game/lobby/social services map their domain types to those structs, so the
wire schema stays out of the domain. **Flagged for R6:** this partly duplicates the gateway encoders
(different source types) — a candidate consolidation.
- **Actor self-fetch killed too** (beyond literal "push"): the `submit_play`/`pass`/`exchange`/`resign`
**response** (`MoveResult`) now returns the actor's refilled rack + bag size, so the mover renders the
next turn from the response — `Game.svelte`'s `commit`/`pass`/`exchange`/`resign` drop their `await load()`.
- **`match_found` enrichment** needs a per-seat initial state: `lobby.GameCreator` gained `InitialState`,
and `game.Service.InitialState` builds the `notify.PlayerState` (rack re-encoded to wire indices, the
variant alphabet embedded for a first-seen variant).
- **UI:** a pure `lib/gamedelta.ts` reducer (`applyMoveDelta` / `applyGameOver` / `seedInitialState`,
unit-tested) advances the cache; `app.svelte` seeds it on `match_found` / `game_started`; `Game.svelte`
applies the delta (falling back to `load()` while composing, on a gap, or on its own move's new rack);
`NewGame.svelte` polls only when `app.streamAlive` is false and guards its teardown so a push-delivered
match is not cancelled.
- **notify (friends/invitations) scope:** the backend carries the full account / invitation payload on the
wire (per "all events → push"); the UI seeds the game cache from `game_started` but keeps its lightweight
**authoritative** badge refresh (`refreshNotifications`, on the rare `notify` event + on foreground) rather
than adding client-side friend/invitation caches — the per-move hot path is fully de-fetched, which was the
goal. Deeper lobby-cache consumption is an easy follow-up.
- **No schema change** (no migration); the contour needs no DB wipe. Tests: `notify` FB round-trips +
`emitMove` delta + the `gamedelta` reducer; the e2e mock now emits the enriched delta.
- **R5** (interview + implementation):
- **No code slimming — by analysis.** A gzip measure + sourcemap attribution of the real `dist` showed
the app bundle is already minified + tree-shaken and dominated by the Connect/FlatBuffers transport
runtime + generated FB/PB bindings (≈⅔ of `main`'s source) and the Svelte runtime — all
third-party/generated, irreducible within R5's scope. App-authored code carries no hand-trimmable fat.
- **Lazy-load rejected** (screens *and* i18n): `bundle-size.mjs` sums every emitted chunk, so
code-splitting moves bytes between chunks for **zero total-size win** while adding request latency (+N
gateway fetches on first navigation to a split screen). i18n lazy-load additionally buys ≤3 KB (en-only
users) at the cost of an async `t()`, and `en` must stay bundled (it is the `MessageKey` type source +
fallback). **Chunk-collapsing rejected** too — keeping the near-static Svelte runtime in its own
cacheable chunk is the recommended practice (an app deploy then re-busts only `main`, not the runtime),
and HTTP/2 makes the extra preload request negligible.
- **Metric retargeted to the app.** The two-entry build (`index.html` app + `landing.html`) makes Rollup
hoist the code shared by both (Svelte runtime + i18n + `aboutContent`) into one preloaded chunk, so the
app actually loads its entry chunk **+ the shared chunk** (≈74 + ≈23 = **≈97 KB**), never `landing.js`
(≈1.6 KB). The old script summed all three chunks (98.8 KB), over-counting the app by `landing.js`.
`bundle-size.mjs` now parses each built HTML for the JS it eagerly loads and gates three parts
independently — **app entry ≤ 100 KB, shared (Svelte+i18n) ≤ 30 KB, landing-own ≤ 5 KB** — reporting the
app total (≈97) and landing total (≈24.5). Same CLI + exit-code contract, so the CI step is unchanged.
- **No app/source/build change** (`App.svelte`, `lib/i18n/`, `vite.config.ts` untouched); no schema
change, no contour wipe. The stale "~82 KB" figure was corrected in `bundle-size.mjs` and `ui/README.md`.
- **R6** (interview + implementation):
- **Locked decisions:** apply **both** wire/code structural changes (**B** + **A**) and **only C1+C2** of
the test consolidation (not C3/C5); strip the `*(Stage N)*` tags from **all current-state docs**
(ARCHITECTURE / FUNCTIONAL+`_ru` / TESTING / UI_DESIGN), keeping PLAN.md / PRERELEASE.md / CLAUDE.md as
history; **split `stage6_test.go`** by domain. The `h2cMaxConcurrentStreams` sizing stays an **R7**
concern (tuning, not behaviour-preserving); the R2 early run forced no code fix, so nothing was carried in.
- **(a) De-staging:** removed the `Stage N` / `TODO-N` / `(RN)` references across code, comments, service
READMEs and the current-state docs, rewording narratives to present tense (no technical content lost).
Renamed the only stage-named identifiers (`registerStage8``registerSocialOps`,
`registerStage11``registerLinkOps`) and split `stage6_test.go` (`TestEmailLoginFlow``email_test.go`;
`TestGuestAutoMatchLeavesNoStats`+`provisionGuest``account_test.go`). De-staged the `.fbs`/`.proto`
comments and regenerated: only the `.proto`-derived Go docstrings (`*_grpc.pb.go`, `push.pb.go`) changed —
flatc strips schema comments, so the FB Go/TS bindings were untouched.
- **(b) Reconciliation:** the docs were accurate (each R-phase baked its own); the one drift was a stale
"guest-reaping deferred (TODO-3)" note in `ARCHITECTURE.md` §3 — guest reaping is implemented, so the
note was replaced with the current behaviour (FUNCTIONAL/TESTING already described it).
- **(c) B — dead `opponent_moved` scalars:** removed `seat/action/score/total` from `OpponentMovedEvent`
(`pkg/fbs/scrabble.fbs` + the `notify` emit + the round-trip test); regenerated FB Go + TS. No reader
used them (the UI codec/mock take `move`/`game`/`bag_len`; the gateway forwards the payload verbatim).
A pre-release wire-slot renumber — free with no prod data, no DB change.
- **(c) A — shared FB builders:** new `scrabble/pkg/wire` holds the single definition of the nested wire
tables (GameView / MoveRecord / StateView / AccountRef / Invitation) shared by the backend `notify`
encoder and the gateway `transcode`; both map their own source types to neutral `wire.*` structs and
delegate. **Honest tradeoff:** the verbose `Start/Add/End` + reverse-prepend boilerplate is now written
once, but the field *set* is still mapped per side, and the new package makes the change net **+~145 LOC**
— a single-source / anti-drift win for the fiddly mechanics rather than a line-count cut. Behaviour-
preserving: the two sides' field sets were verified identical and the round-trip tests pass unchanged.
- **(c) C1+C2 — inttest fixtures:** moved the cross-file service/game fixtures (`newGameService` was used by
10 files) into `backend/internal/inttest/helpers.go`; single-file helpers stay local. Pure relocation.
- **No schema change → no contour DB wipe.** Regression gate: the full unit + integration + UI suites plus
the R7 stress run.
- **R7** (interview + implementation):
- **Locked decisions:** run the harness **same-host** (one-shot container on `scrabble-internal`, capped
`--cpus=3` so the contour keeps spare cores); **apply container limits + `GOMAXPROCS` now** (not just a
prod recommendation); **replace cAdvisor with the otelcol `docker_stats` receiver** (it resolved only the
root cgroup on this host); keep rate-limit / h2c knobs **compiled-in** (change values only if the data
demands — it did not).
- **Harness refinements (pre-run):** each virtual player builds its **own `edge.Client`** (its own h2c
connection for its Subscribe stream + Execute calls) instead of all players sharing one `http2.Transport`
the R2 `transport_error` artifact; and `playTurn` now reports a **finished** game so the player drops it
from rotation. Effect, measured: `game.state` `transport_error` 14 % (R2) → **2.49 %**; `game_finished` on
chat ≈ 3 900 → **35**.
- **Observability:** added the `docker_stats` receiver to `otelcol` (`api_version: "1.44"` — the daemon's
minimum is 1.40; the receiver defaults to 1.25 and crash-looped until pinned), mounted the docker socket
read-only with `group_add` (the contrib image runs as UID 10001), dropped the cAdvisor service + its
Prometheus job, and retargeted the **Scrabble — Resources** dashboard to the docker_stats metric names
(`container_cpu_utilization`/100 == cores). Cross-checked against `docker stats` within sampling error.
- **Profile (final run, 500 players, limits in force):** the **gateway is the binding constraint** — with
one connection per player it bursts into its 2-core cap (the residual 2.49 % `transport_error`); backend
~0.85 core and postgres ~1.4 cores had headroom; **tempo reached its 1 GiB cap**; the backend pool sat at
its `MaxOpenConns=25` cap (28 backends); docker logs were unbounded (~14 MiB / 30 min on the backend at
info). Full write-up in [`../loadtest/REPORT.md`](../loadtest/REPORT.md). *(Superseded in part: a
later pass modelling the `game.evaluate` hot path traced the gateway's CPU appetite to
**gateway→backend connection churn** — the default 2-idle-connection HTTP transport — not proxying
work. Pooling the connections cut peak gateway CPU ~7× (~1.75 → ~0.26 cores at 500 players) and
removed the ephemeral-port-exhaustion cliff behind the residual `transport_error`, so the gateway is
no longer the binding constraint — postgres is. The 3-core gateway cap below is now generous headroom.)*
- **Round-2 tuning (owner-agreed, all in `deploy/docker-compose.yml`, no code change):** gateway **2 → 3
cores + `GOMAXPROCS=3`**; tempo memory **1 → 2 GiB**; backend `MAX_OPEN_CONNS` **25 → 40**; a json-file
**log-rotation** default (10m × 3) applied contour-wide via a YAML anchor (level stays info).
backend/postgres kept at 2 cores / 512 MiB (headroom is cheap on the shared host).
- **Validation:** the same gradual ramp on the tuned contour cut `game.state` `transport_error` to **0.72 %**
(gateway ~2 cores, now under the 3-core cap, no throttle; tempo ~1.27 GiB, under 2 GiB). A separate
**burst** run (a single 100 → 500 jump) pegged the gateway at 3 cores (≈296 % sustained, 9.27 % error),
confirming it is **connection-CPU-bound** — a true arrival spike is a **horizontal-scaling** lever, not
more cores per node (recorded in the prod-sizing recommendation).
- **No schema change → no contour DB wipe.** Bake-back: `loadtest/REPORT.md`, `loadtest/README.md`,
`docs/TESTING.md`, the telemetry/observability section of `docs/ARCHITECTURE.md`, the repo-layout line in `CLAUDE.md`.
- **UI — Tab-bar navigation redesign** (owner ad-hoc, not on the raw TODO list): drop the hamburger
`Menu.svelte` everywhere (it fought the Telegram-fullscreen layout, where it had to be re-centred).
- **Locked decisions (interview):** the in-Settings sub-nav is a **bottom TabBar with the active tab
highlighted** (icon-only); **Export GCG** moves to the left slot of the move-history header (free in a
finished game, where 🏁 *leave* does not apply); the lobby **⚙️ badge counts incoming friend requests
only** (invitations keep their own lobby section); unread chat is badged on **the score bar and the 💬**.
- **What shipped:** a ⚙️ **Settings hub** (`screens/SettingsHub.svelte`) over the existing
Settings/Profile/Friends/About bodies and an in-game **comms hub** (`game/CommsHub.svelte`) over
chat + dictionary, both with in-place tabs and a fixed back target; the game's menu items relocate into
the open move history (🏁 leave / 📤 export + 💬 comms header) and the player cards (🤝 add-friend); a
shared **TapConfirm** (`components/TapConfirm.svelte`, `lib/tapconfirm.ts`) — tap → fading ✅ → tap —
replaces the Skip/Hint press-and-hold popovers and drives the add-friend confirm. Fixed the move-history
"jump" bug (the slid board is now inert and the stage can't scroll, so a swipe up genuinely closes it).
`Menu.svelte` + `HoldConfirm.svelte` removed.
- **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md`
(+`_ru`). Regression gate: UI `check` + unit (`tapconfirm`) + build + bundle budget + e2e (Chromium &
WebKit), all green.
- **UI — Merge Exchange/Pass; drop the dead Tournaments tab** (owner ad-hoc, not on the raw TODO
list): the lobby's 🏆 *Tournaments* tab was an inert `lobby.soon` toast — removed (the lobby is back
to three tabs, matching `docs/FUNCTIONAL.md`). In-game the separate 🥺 *Skip* (pass) tab folds into
the 🔄 tab, now **Exchange/Pass**, whose dialog passes when no tile is selected and exchanges when
tiles are.
- **Decision — a pass is NOT an exchange of zero (verified against the rules + GCG):** the merge is
**UI-only**. Pass and exchange stay distinct game actions end-to-end — wire (`GameActionRequest` vs
`ExchangeRequest`), engine (`ActionPass` vs `ActionExchange`), and the GCG Poslfit dialect (a pass is
a bare `-`, an exchange is `-TILES`). The engine forbids a zero-tile exchange (`ErrNothingToExchange`)
and allows an exchange only with a full rack left in the bag (`ErrNotEnoughTilesToExchange`), while a
pass is always legal — collapsing them would lose a real distinction. The dialog dispatches the
existing `gateway.pass` / `gateway.exchange`.
- **What shipped:** `Lobby.svelte` (tab removed); `Game.svelte` (one 🔄 Exchange/Pass tab no longer
gated on an empty bag; the dialog disables tile selection while the bag is below a full rack
(`bagLen >= RACK_SIZE`), its confirm button reading **Pass without exchanging** / **Exchange N**);
i18n (`game.draw` → Exchange/Pass, new `game.passNoExchange`, dropped `game.skip` /
`lobby.tournaments` / `lobby.soon`). No backend/wire/history/GCG change.
- **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md`
(+`_ru`). Regression gate: UI `check` + unit + build + bundle budget + e2e (Chromium & WebKit).
- **AI — Honest AI opponent in quick game** (owner ad-hoc, not on the raw TODO list): a second quick-game
opponent the player *knowingly* chooses, distinct from the disguised robot of the random/open path
(which is kept as-is). New Game's quick-game mode replaces the "auto-match" subtitle with a two-button
selector **🤖 AI / 👤 Random player** (the `.seg`/`.opt` segmented style, AI the default); for AI the
move-clock line reads "Loss after 7 days of inactivity" and the "searching" hint is hidden.
- **Locked decisions (interview):** AI move is **event-driven** (the robot replies the instant the
player's move commits; the 30 s driver is the fallback); AI games **do not touch `account_stats`**
(practice, like guests); the **Stage 5 strength logic is reused unchanged** (`playToWin` 40 % from the
seed + margin band); **no per-move timeout — a 7-day inactivity loss** instead; the 7-day line lives on
the New Game screen (the in-game screen has no move-clock line); chat + nudge **disabled**, word-check
kept, add-friend never drawn, opponent shown as **🤖** everywhere.
- **The 7-day rule reuses the existing per-turn timeout:** an AI game is created with
`turn_timeout_secs = AIInactivityTimeout` (7 days) and the existing timeout sweeper resigns the overdue
seat — since the robot moves at once, only the human is ever on the clock, so the per-turn timeout *is*
the abandon rule (no new column, no new sweeper).
- **One game flag drives everything:** `games.vs_ai` (edited into the R1 baseline — pre-release, so a
contour DB wipe after merge). It is set **only** on AI-started games, so a robot-filled random game keeps
`vs_ai=false` and the disguised opponent is never revealed; the UI derives 🤖 / the gates **from the flag,
never from the opponent account**. New backend path `Matchmaker.StartVsAI` (picks a pooled robot via the
existing `Pick`, creates an **active** seated game via `game.Service.Create`, random seat order) — the AI
request never enters the open pool, so the open-game reaper never touches it. The robot driver gains a
`vs_ai` branch (no sleep, no proactive nudge, zero delay) and a focused `DriveGame`/`TriggerMove` fast
path wired from the game service's after-create/after-commit hook (`SetAITrigger`, a func value so the
game package never imports the robot package). Chat/nudge gated by a new `social` `VsAI` check
(`ErrGameVsAI` → 409 `ai_game`); statistics skipped in `commit` when `vs_ai`.
- **Wire:** `EnqueueRequest` += `vs_ai`, `GameView` += `vs_ai` (trailing FB fields, regenerated Go + TS),
threaded through the backend DTO, the gateway transcode and the `pkg/wire` + `notify` builders.
- **Tests:** `lobby` unit (StartVsAI seats a robot + flags the game; empty pool leaves no game); backend
integration (`ai_game_test.go`: active+seated+vs_ai+7-day clock, robot moves immediately, stats skipped,
7-day timeout resigns the human, chat/nudge rejected); UI codec round-trip (`vs_ai` on enqueue + game
view); e2e (an AI game shows 🤖, no "searching", chat disabled, the dictionary still works) + the
existing quick-match e2e updated to pick **Random player** (the default is now AI).
- **Schema/wire change → a contour DB wipe** after merge (`DROP SCHEMA backend CASCADE` + restart, the
R1/R3 pattern). Bake-back: `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `docs/UI_DESIGN.md`,
`backend/README.md`, Go Doc comments.
- **Post-review refinements (owner, same PR):** (1) the **GCG export labels the robot seat "AI"** rather
than its human-like pool name (`ExportGCG` overrides the name via `accounts.IsRobot`; the in-app 🤖 is
unchanged); (2) honest-AI games **emit no `your_turn`** — the robot replies instantly, so the signal
would arrive with the move and be pointless; `opponent_moved` still advances the UI; (3) the **admin
console surfaces the AI flag** — a **🤖 column** in `/games` and an "AI game" line on the game card
(`GameRow`/`GameDetailView` gain `VsAI`); (4) `games_started_total` / `games_abandoned_total` gain a
**`vs_ai`** attribute and the Grafana *Game domain* dashboard splits started/abandoned into **human**
and **AI** panels.
- **Follow-up (separate PR — strategy deviation):** the robot now plays **≈20%** of opening/midgame moves
*against* its per-game `playToWin` intent (toward the opposite margin band — a winning robot eases off, a
losing one surges ahead), tapering linearly to **0 over the last 14 bag tiles** and **0 once the bag is
empty**, so the endgame follows the chosen strategy strictly while earlier outcomes can swing the human's
way. Deterministic from the seed (`mix(seed,"deviate",moveCount)`), applied to **both** robot paths via
the shared `selectMove`; the per-game intent (and the admin card) is unchanged. Tests: `robot` unit
(taper bounds + monotonicity, never-in-endgame, determinism, ~20% distribution). Bake-back:
`docs/ARCHITECTURE.md` §7, `docs/FUNCTIONAL.md` (+`_ru`), `backend/README.md`, `PLAN.md` Stage 5.
- **GL — Simultaneous quick-game cap** (owner ad-hoc, not on the raw TODO list): a player may hold at
most **10** active quick games; at the cap the lobby greys **New Game** and shows a plain notice
"Вы достигли лимита одновременных партий", both clearing automatically when an active game finishes.
- **Locked decisions (interview):** what counts = active **+** open (searching) quick games, **including
AI** (`vs_ai`); friend games (invitation-linked) **never** count. The backend gate refuses **all** new-game
creation at the cap — `lobby/enqueue` **and** `invitations` — with **409 `game_limit_reached`**; **accepting**
an invitation is never gated, so friend games are capped "from the other end". Delivery = a boolean
**`at_game_limit`** on the existing `games.list` (no per-event payload: a turn change does not move the count,
and the lobby already re-fetches `games.list` on entry + every game event); the first uncached lobby frame
defaults the button **enabled** (the backend gate is the authority).
- **What shipped:** `game.MaxActiveQuickGames` + `Store/Service.CountActiveQuickGames` (active/open seats, no
`game_invitations` row; hidden games still count → a dedicated count, not a filter over the lobby list);
`Server.atGameLimit`/`ensureUnderGameLimit` gating `handleEnqueue` + `handleCreateInvitation`;
`gameListDTO.at_game_limit`; the FB `GameList` trailing `at_game_limit` (regenerated Go + TS) threaded through
the gateway transcode + UI codec; `lib/model` + `lobbycache` snapshot + `Lobby.svelte` (disabled tab + a muted
`.limit` notice); i18n `lobby.limitReached` (en authoritative + ru).
- **Caveat (logged):** the gate is a pre-check, not transaction-atomic — concurrent creates from one account could
momentarily exceed by 12 (harmless soft cap; the UI disables the button regardless). Strict atomicity was judged
a disproportionate diff across the two create paths.
- **No schema change → no contour DB wipe** (only a trailing FB field, no migration). Tests: backend integration
(`game_limit_test.go`: count rule + HTTP gate 409 + accept bypass), server unit (error mapping), gateway
transcode round-trip, UI codec + lobbycache unit, e2e (`gamelimit.spec.ts`). Bake-back: `docs/FUNCTIONAL.md`
(+`_ru`), `docs/ARCHITECTURE.md` §8, `docs/UI_DESIGN.md`, `backend/README.md`.
- **CM — Channel-chat moderation + promo bot** (owner ad-hoc, not on the raw TODO list):
- **Locked decisions (interview):** the promo bot is a **goroutine in `cmd/bot`** (its own token, no
bot-link); the moderated chat's default-no-send is configured by a **human** in the group settings (the
bot only grants, never `setChatPermissions`); a non-eligible joiner is **left muted silently**; a
temporary-suspension expiry is handled by a **backend sweeper** that emits the re-evaluate event; and a new
**`chat_muted` role** is a chat-only mute with the **game suspension dominating**
(`eligible = registered AND NOT suspended AND NOT chat_muted`).
- **Bot API reality (verified against the docs):** a cross-bot Mini App launch must be a **URL button** to the
main bot's `t.me/<bot>?startapp` link — a `web_app` button signs initData with the *sending* bot's token,
which the main validator rejects — so the promo button reuses the UI's `VITE_TELEGRAM_LINK`. `chat_member`
updates arrive **only** when the bot is a chat **admin** with the "Ban users" right (the client label for the
Bot API `can_restrict_members`) and `chat_member` is in `allowed_updates`; bots cannot list members but can
`getChatMember` a single user, which is the membership guard on the block/unblock path.
- **Wire:** `pkg/proto/botlink/v1` gains a `ChatGateCommand` in the `Command` oneof and a unary
`ResolveChatEligibility`; the backend gains `notify.KindChatAccessChanged` (no payload, infra-only — never an
out-of-app message) and an internal `POST /api/v1/internal/chat-access` resolver; the gateway resolves the
join (by external_id) and the event (by user_id) through it and pushes the chat-gate command fire-and-forget
(at-most-once, recovered by the next moderation action or a re-join).
- **No schema change → no contour DB wipe:** `chat_muted` is a new `account.KnownRoles` entry (the
`account_roles` table is data-driven). The suspension-expiry sweeper is a new `account.SuspensionSweeper`
(a 1-minute window, idempotent) started in `cmd/backend`, alongside the guest reaper.
- **Deploy:** new `TEST_`/`PROD_` `TELEGRAM_PROMO_BOT_TOKEN` (secret), `TELEGRAM_BOT_USERNAME` and
`TELEGRAM_CHAT_ID` (variables); the promo link reuses the existing `*_VITE_TELEGRAM_LINK` variable as
`TELEGRAM_BOT_LINK`. The bot must be promoted to admin in the real discussion group, and the group default
set to no-send, as part of the Stage 18 prod cutover (the test contour exercises the code path).
- **Bake-back:** `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `platform/telegram/README.md`,
`backend/README.md`, Go Doc comments. Tests: backend resolver truth table + publish on block/unblock/role +
the sweeper window (unit + integration); gateway hub `ResolveChatEligibility` + the chat-gate command; bot
`chat_member` grant + `ApplyChatGate` getChatMember-guard; promo `/start` localization + URL button; config
parsing.
- **Post-contour-test fixes (same PR):** a live test drove three corrections. (1) **Strategy
inversion (the key one)** — the original "group default no-send, bot grants the eligible" cannot
work: Telegram intersects the chat default with each user's permission, so a per-user grant never
exceeds a deny-by-default group (the bot set `can_send=true` yet the user still could not write).
The group now **allows sending by default** and the bot only **restricts** — it mutes an ineligible
member (unregistered / admin-suspended / `chat_muted`) and un-mutes an eligible one it had muted,
acting only when the current state differs (idempotent; the bot's own change is skipped by matching
the actor id to the bot). A present member in a default-allow group can appear as `restricted` with
`is_member`, so the gate reads both. (2) **Join-before-register** — a user who joins before
registering is covered by no `chat_member` event, so `ProvisionTelegram` now reports first contact
and the Telegram auth handler emits `chat_access_changed` on it. (3) **Observability** — a startup
self-check logs whether the bot is an admin-with-restrict in the chat (it caught a misconfigured
`TELEGRAM_CHAT_ID` set to a channel id, not the discussion-group id); the per-event trace is at
Debug, the actual mute/unmute and warnings at Info.
+3 -4
View File
@@ -22,9 +22,8 @@ supports English Scrabble, Russian Scrabble and Эрудит.
security, cross-service contracts. security, cross-service contracts.
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)) — - [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)) —
per-domain user stories. per-domain user stories.
- [`docs/TESTING.md`](docs/TESTING.md) — test layers and the per-stage CI gate. - [`docs/TESTING.md`](docs/TESTING.md) — test layers and the CI gate.
- [`PLAN.md`](PLAN.md) — the staged implementation plan and stage tracker. - [`CLAUDE.md`](CLAUDE.md) — project guide and development workflow.
- [`CLAUDE.md`](CLAUDE.md) — project guide and the mandatory per-stage workflow.
## Build & test ## Build & test
@@ -90,7 +89,7 @@ observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a fron
services build from multi-stage distroless `*/Dockerfile`. services build from multi-stage distroless `*/Dockerfile`.
```sh ```sh
docker build -f backend/Dockerfile -t scrabble-backend . # pulls the DAWG release artifact docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required; pulls that DAWG release artifact
docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env) docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
``` ```
+41
View File
@@ -0,0 +1,41 @@
# Erudit — site icons + Open Graph card
The favicon set and the `og:image` link-preview card for the public landing
(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the
[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`),
with the wordmark on the app's dark board green (`ui/src/app.css` tokens).
| Output (committed to `ui/public/`) | Purpose |
|------|---------|
| `favicon.svg` | Vector favicon, transparent; tile + «Э» only (the score is illegible below ~32 px). |
| `favicon.ico` | 32×32 PNG-in-ICO fallback (also answers the browsers' blind `/favicon.ico` probe). |
| `apple-touch-icon.png` | 180×180 opaque full-bleed tile; iOS masks its own corners. |
| `og-image.png` | 1200×630 card: tile + «Эрудит / Скрэббл — игра в слова». Referenced absolutely as `https://erudit-game.ru/og-image.png`. |
## How it works
`build/extract.js` extracts the needed glyph outlines (tile glyphs + every wordmark
character) from LiberationSans (Arial-metric, the game's font stack) with their
advance widths into `build/glyphs.json` (committed). `build/generate.js` composes
plain SVG from those outlines — no font is needed at generation time — writes
`favicon.svg` and rasterises the PNG/ICO outputs by screenshotting the SVGs with the
`ui` package's Playwright chromium (`@playwright/test`); the `.ico` container is
assembled in-script (a single PNG entry). Raster bytes therefore depend on the
installed chromium version; the SVG sources are deterministic.
## Regenerate
Requirements: Node ≥ 18, `ui` installed (`pnpm install`, provides Playwright).
`extract.js` additionally needs `opentype.js` (`npm i opentype.js`); **`generate.js`
needs no extra packages**.
```sh
cd assets/icons
# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes:
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
# 2. regenerate everything in ui/public/:
node build/generate.js
```
+78
View File
@@ -0,0 +1,78 @@
'use strict';
// Extract the glyph outlines the site icons and the og-image wordmark need from a
// grotesque font (LiberationSans = Arial-metric, matching the game's system-ui/Arial
// stack) and emit cubic-bezier contours per character, baseline at y=0, y-down,
// plus the advance width so generate.js can lay out words without the font.
// Same outline conversion as ../../vk/build/extract.js, generalised to a char set.
const opentype = require('opentype.js');
const fs = require('fs');
const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
const b = fs.readFileSync(FONT);
const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
const FS = 1000; // em scale
// The tile glyphs («Э», «8») + every character of the og-image wordmark lines
// («Эрудит», «Скрэббл — игра в слова»). The space carries only an advance.
const CHARS = [...new Set('Э8рудитСкэббл—игра в слова')];
function glyphData(ch) {
const g = font.charToGlyph(ch);
const p = g.getPath(0, 0, FS); // baseline at y=0, y-down
const contours = [];
let cur = null, prev = null;
for (const c of p.commands) {
if (c.type === 'M') {
if (cur) contours.push(cur);
cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
prev = { x: c.x, y: c.y };
} else if (c.type === 'L') {
cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'C') {
cur[cur.length - 1].o = [c.x1, c.y1];
cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'Q') {
const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
cur[cur.length - 1].o = c1;
cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'Z') {
if (cur && cur.length > 1) {
const last = cur[cur.length - 1], first = cur[0];
if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
first.i = last.i; // fold the duplicate closing point into the first
cur.pop();
}
}
if (cur) { contours.push(cur); cur = null; }
}
}
if (cur) contours.push(cur);
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
const out = contours.map(ct => {
const v = [], i = [], o = [];
ct.forEach(pt => {
v.push(pt.v);
i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
});
return { i, o, v, c: true };
});
const bbox = out.length
? { x: minx, y: miny, w: maxx - minx, h: maxy - miny }
: { x: 0, y: 0, w: 0, h: 0 }; // the space has no outline
return { adv: g.advanceWidth * (FS / font.unitsPerEm), bbox, contours: out };
}
const glyphs = {};
for (const ch of CHARS) glyphs[ch] = glyphData(ch);
const out = __dirname + '/glyphs.json';
fs.writeFileSync(out, JSON.stringify({ em: FS, glyphs }));
console.log('wrote', out, fs.statSync(out).size, 'bytes;', CHARS.length, 'glyphs:', CHARS.join(''));
+128
View File
@@ -0,0 +1,128 @@
'use strict';
// Site icons + the Open Graph card for the public landing, drawn from the same
// design as ../../vk (the wooden Erudit tile: face «Э», score «8») and the app's
// board palette (ui/src/app.css). Everything is composed as SVG from the committed
// glyph outlines (build/extract.js -> glyphs.json), so no font is needed at build
// time; the PNG/ICO rasters are screenshots taken with the ui package's Playwright
// chromium (@playwright/test re-exports the browser API). Outputs go straight to
// ui/public/:
// favicon.svg 96 viewBox, transparent, tile + «Э» (the score is illegible small)
// favicon.ico 32x32 PNG-in-ICO render of the same
// apple-touch-icon.png 180x180 opaque full-bleed tile (iOS masks its own corners)
// og-image.png 1200x630 card: tile + wordmark on the board green
const fs = require('fs');
const path = require('path');
const G = JSON.parse(fs.readFileSync(path.join(__dirname, 'glyphs.json'), 'utf8'));
const UI = path.resolve(__dirname, '../../../ui');
const OUT = path.join(UI, 'public');
// ---- palette (vk loader tile + app.css board tokens) ------------------------
const FACE = '#D9B978', BORDER = '#B49559', GLYPH = '#1A1A1A';
const BOARD_DARK = '#2a3330', TEXT = '#e7ece8', TEXT_MUTED = '#cdd6cf';
// ---- glyph outlines -> SVG path data ----------------------------------------
const r2 = n => Math.round(n * 100) / 100;
// pathD renders one glyph's contours scaled by sc and translated by (tx, ty).
function pathD(g, sc, tx, ty) {
const pt = (v, d) => `${r2(v[0] * sc + tx + d[0] * sc)} ${r2(v[1] * sc + ty + d[1] * sc)}`;
const Z = [0, 0];
return g.contours.map(ct => {
const n = ct.v.length;
let d = `M${pt(ct.v[0], Z)}`;
for (let k = 1; k <= n; k++) {
const a = k - 1, b = k % n;
d += `C${pt(ct.v[a], ct.o[a])} ${pt(ct.v[b], ct.i[b])} ${pt(ct.v[b], Z)}`;
}
return d + 'Z';
}).join('');
}
// glyphAt centres a glyph's bbox at (cx, cy) with the given pixel cap height, the
// same placement rule as the vk loader's glyph(). stroke fattens it slightly.
function glyphAt(ch, capPx, cx, cy, stroke, colour) {
const g = G.glyphs[ch], sc = capPx / g.bbox.h;
const d = pathD(g, sc, cx - (g.bbox.x + g.bbox.w / 2) * sc, cy - (g.bbox.y + g.bbox.h / 2) * sc);
return `<path d="${d}" fill="${colour}" stroke="${colour}" stroke-width="${r2(stroke)}"/>`;
}
// textLine lays out a string on a baseline from the per-glyph advances; capPx sets
// the capital height (measured on «Э»). Returns the combined path + the width.
function textLine(str, capPx, x, y, colour) {
const sc = capPx / G.glyphs['Э'].bbox.h;
let d = '', w = 0;
for (const ch of str) {
const g = G.glyphs[ch];
if (g.contours.length) d += pathD(g, sc, x + w, y);
w += g.adv * sc;
}
return { svg: `<path d="${d}" fill="${colour}"/>`, width: w };
}
// tile draws the rounded wooden tile centred at (cx, cy): size px wide/high, with
// the vk loader's corner (6/52) and rim proportions, «Э» and optionally the «8».
function tile(cx, cy, size, withScore) {
const h = size / 2, rx = size * (6 / 52), rim = size * (1.8 / 52);
let s = `<rect x="${r2(cx - h + rim / 2)}" y="${r2(cy - h + rim / 2)}" width="${r2(size - rim)}" height="${r2(size - rim)}" rx="${r2(rx)}" fill="${FACE}" stroke="${BORDER}" stroke-width="${r2(rim)}"/>`;
s += glyphAt('Э', size * (32 / 52), cx, cy - size * (1 / 52), size * (1 / 52), GLYPH);
if (withScore) s += glyphAt('8', size * (8.5 / 52), cx + size * (19.5 / 52), cy + size * (18.5 / 52), size * (0.5 / 52), GLYPH);
return s;
}
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
// ---- favicon.svg (the committed vector master) -------------------------------
const favicon = svg(96, 96, tile(48, 48, 88, false)) + '\n';
// ---- apple-touch-icon: opaque full bleed, iOS applies its own corner mask ----
const appleTouch = svg(180, 180,
`<rect width="180" height="180" fill="${FACE}"/>` +
`<rect x="8" y="8" width="164" height="164" rx="18" fill="none" stroke="${BORDER}" stroke-width="4"/>` +
glyphAt('Э', 100, 90, 88, 3, GLYPH) +
glyphAt('8', 26, 146, 142, 1.5, GLYPH));
// ---- og-image: tile + wordmark, centred as one group on the board green ------
function ogImage() {
const W = 1200, H = 630, tileSize = 340, gap = 84;
const l1 = textLine('Эрудит', 112, 0, 0, TEXT);
const l2 = textLine('Скрэббл — игра в слова', 44, 0, 0, TEXT_MUTED);
const textW = Math.max(l1.width, l2.width);
const left = (W - (tileSize + gap + textW)) / 2;
const tx = left + tileSize + gap;
// Two baselines around the vertical centre; the tile centre sits between them.
const b1 = 295, b2 = 408;
const body =
`<rect width="${W}" height="${H}" fill="${BOARD_DARK}"/>` +
tile(left + tileSize / 2, H / 2, tileSize, true) +
textLine('Эрудит', 112, tx, b1, TEXT).svg +
textLine('Скрэббл — игра в слова', 44, tx, b2, TEXT_MUTED).svg;
console.log(`og-image: text ${Math.round(textW)}px wide, group left ${Math.round(left)}px`);
return svg(W, H, body);
}
// ---- rasterisation (Playwright chromium from ui/node_modules) ----------------
async function shoot(page, markup, w, h, transparent) {
await page.setViewportSize({ width: w, height: h });
await page.setContent(`<body style="margin:0">${markup}</body>`);
return page.screenshot({ omitBackground: transparent });
}
// icoFromPNG wraps one PNG as a single-entry .ico (ICONDIR + ICONDIRENTRY + PNG).
function icoFromPNG(png, sizePx) {
const h = Buffer.alloc(22);
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4); // icon, 1 image
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7); // 32x32
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12); // planes, 32bpp
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18); // size, offset
return Buffer.concat([h, png]);
}
(async () => {
fs.writeFileSync(path.join(OUT, 'favicon.svg'), favicon);
const { chromium } = require(path.join(UI, 'node_modules', '@playwright/test'));
const browser = await chromium.launch();
const page = await browser.newPage();
const fav32 = await shoot(page, svg(32, 32, tile(16, 16, 29.33, false)), 32, 32, true);
fs.writeFileSync(path.join(OUT, 'favicon.ico'), icoFromPNG(fav32, 32));
fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), await shoot(page, appleTouch, 180, 180, false));
fs.writeFileSync(path.join(OUT, 'og-image.png'), await shoot(page, ogImage(), 1200, 630, false));
await browser.close();
for (const f of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'og-image.png']) {
console.log('wrote', path.join(OUT, f), fs.statSync(path.join(OUT, f)).size, 'bytes');
}
})();
File diff suppressed because one or more lines are too long
+115
View File
@@ -0,0 +1,115 @@
# Erudit — VK loading-screen logo (Lottie)
Animated logo for the **VK app loading screen** (shown before the SPA assets load).
The Erudit «Э» tile (score `8`) **drops in under gravity, lands with a soft squash —
its left/right edges bulging into a cushion — then springs back up**, looping. A light
"glint" is caught at the moment of the bounce.
| File | Purpose |
|------|---------|
| `erudit-loader.json` | The Lottie to upload to VK. |
| `erudit-loader-preview.gif` | Looping preview. Rendered on a green "baize" background **only in the preview** — the real asset has a **transparent** background. |
| `build/` | The reproducible build pipeline (see *Regenerate*). |
**VK requirements met:** 96×96 px · vector Lottie JSON · ≤ 24 KB (~11 KB) · seamless
~1.1 s loop · transparent background.
Design reference: a photo of the physical wooden Erudit tile.
---
## How it works
A single flat layer holds the tile — a rounded-rect **face path**, the two glyphs, and
a thin border — and everything is driven by that layer's transform plus a few colour /
shape tracks. The anchor sits on the tile's **bottom edge**, so the squash happens
against the floor.
### Bounce (gravity)
`position.y` of the bottom edge goes apex → floor → apex with **no dwell** (the apex is
an instantaneous turn-around). The fall uses a strong **ease-in** (accelerate) and the
rise a strong **ease-out** (decelerate), so it reads as real gravity. While falling fast
the tile slightly **stretches** (tall+thin); that is the squash-and-stretch setup.
### Soft cushion (squash)
On contact the layer **squashes** (scaleY↓, scaleX↑) about the bottom anchor, with a
touch of skew. Crucially the squash/cushion is **decoupled from the fall speed** — it
eases in and out *slowly* (`ES`), so the landing feels soft even though the fall is
fast. The squash is deliberately gentle (≈ 86 % / 112 %).
### The cushion shape (the hard part)
The face is **not** a plain rounded rect — it is a path whose left/right contour bows
out into a convex cushion on impact:
- the rest rounded-rect outline is sampled (fine corner arcs + edge mids), and on impact
every point is pushed outward by a smooth **barrel** profile `f(y) = b·(1 (y/HH)²)`
(max at the middle, fading to zero at the top/bottom);
- so the **whole side — corners included — bows out as one piece**, never just the
straight middle;
- bezier handles are computed as a **chordal spline** (handle length ∝ the adjacent
chord), which stays smooth across the uneven corner/edge point spacing. This is what
keeps the corner↔cushion junction kink-free — both at rest and fully bulged — which a
naïve uniform Catmull-Rom (or moving discrete points with fixed tangents) does not.
### Glint
At the bounce the face flashes a little lighter (`FACE_GLINT`) and the glyphs warm
toward brown-red (`BLACK_LIT`), then settle back. A cheap, warm "catch the light".
### Border & background
The tile carries a thin static **border** a touch darker than the face (`BORDER`) so it
reads on any background. The **green baize background is added only when rendering the
preview GIF** — the Lottie itself is transparent.
### Player compatibility
Only plain 2-D shapes (`ddd:0`) — no 3-D layers, expressions or effects — so every
Lottie player (lottie-web on the web, rlottie on native) renders it identically.
---
## Glyphs
`Э` (U+042D) and `8` are **real outlines from LiberationSans-Regular** — metric-
compatible with Arial, matching the game's `--font: system-ui … Arial` stack
(see `ui/src/app.css`). `build/extract.js` pulls the contours into `build/glyphs.json`
as Lottie cubic paths; a hair of same-colour stroke adds a touch of weight.
---
## Regenerate
Requirements: Node ≥ 18. `extract.js` additionally needs `opentype.js`
(`npm i opentype.js`); **`generate.js` has no dependencies**.
```sh
cd assets/vk
# 1. (optional) re-extract the glyphs — only if you change the font:
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
# 2. build the animation (reads build/glyphs.json):
node build/generate.js erudit-loader.json # -> erudit-loader.json
# 3. (optional) live preview — needs lottie-web (npm i lottie-web):
node build/build-preview.js erudit-loader.json preview.html
# then open preview.html in a browser.
```
The preview GIF is assembled by rendering the Lottie frame-by-frame (lottie-web canvas,
filled with the green baize) and stitching with `ffmpeg`; the live HTML preview is the
quickest way to eyeball changes.
## Tunables (top of `build/generate.js`)
| Symbol | Meaning |
|--------|---------|
| `OP`, `FR` | loop length (frames) / fps — overall speed |
| `T_HIT / T_PEAK / T_LIFT / T_REC` | beats: contact / squash peak / lift-off / cushion recovered |
| `EI / EO / ES` | easings: fast fall / fast rise / slow soft cushion |
| `APEX`, `FLOOR` | bottom-edge screen-y at the top of the bounce / at rest |
| `HW`, `HH`, `CR` | tile half-width / half-height / corner radius |
| `scl` squash values | the squash amount (scaleX↑ / scaleY↓) |
| `bulge` `b` | cushion depth (how far the sides bow out) |
| `FACE / FACE_GLINT` | wood face / glint flash |
| `BORDER` | tile rim colour |
| `BLACK / BLACK_LIT` | glyph / glyph-at-glint (brown-red) |
| preview bg `#3C7858` | the green-baize colour used **only** in the GIF |
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const fs = require('fs');
const data = fs.readFileSync(process.argv[2] || 'erudit.json', 'utf8');
const lottiePath = __dirname + '/node_modules/lottie-web/build/player/lottie.min.js';
const html = `<!doctype html><html><head><meta charset="utf8"><style>
body{margin:0;background:#2b2b2b;font-family:sans-serif;color:#ccc}
.row{display:flex;gap:18px;padding:18px;align-items:flex-end;flex-wrap:wrap}
.cell{text-align:center}
.chk{background-image:linear-gradient(45deg,#8a8a8a 25%,transparent 25%),linear-gradient(-45deg,#8a8a8a 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#8a8a8a 75%),linear-gradient(-45deg,transparent 75%,#8a8a8a 75%);background-size:16px 16px;background-position:0 0,0 8px,8px -8px,-8px 0;background-color:#b5b5b5}
.s96{width:96px;height:96px}.big{width:336px;height:336px}small{font-size:11px}
</style></head><body><div class="row" id="row"></div>
<script src="./lottie.min.js"></script>
<script>
const animationData=${data};window.AD=animationData;
const row=document.getElementById('row');window.anims=[];
function make(cls,frame,label){
const cell=document.createElement('div');cell.className='cell';
const box=document.createElement('div');box.className='chk '+cls;cell.appendChild(box);
cell.appendChild(document.createElement('br'));
const cap=document.createElement('small');cap.textContent=label;cell.appendChild(cap);
row.appendChild(cell);
const a=lottie.loadAnimation({container:box,renderer:'svg',loop:false,autoplay:false,animationData:JSON.parse(JSON.stringify(animationData))});
a.addEventListener('DOMLoaded',()=>a.goToAndStop(frame,true));
window.anims.push(a);
}
[0,22,45,68].forEach(f=>make('s96',f,'f'+f));
make('big',22,'f22 x3.5');
window.__ready=true;
</script></body></html>`;
fs.writeFileSync(process.argv[3] || 'preview.html', html);
console.log('wrote', process.argv[3] || 'preview.html');
+67
View File
@@ -0,0 +1,67 @@
'use strict';
// Extract the Cyrillic "Э" (U+042D) and the digit "8" outlines from a grotesque
// font (LiberationSans = Arial-metric, matching the game's system-ui/Arial stack)
// and emit Lottie cubic-bezier contours, centred at the origin, y-down.
const opentype = require('opentype.js');
const fs = require('fs');
const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
const b = fs.readFileSync(FONT);
const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
const FS = 1000; // em scale
function glyphContours(ch) {
const p = font.charToGlyph(ch).getPath(0, 0, FS); // baseline at y=0, y-down
const contours = [];
let cur = null, prev = null;
for (const c of p.commands) {
if (c.type === 'M') {
if (cur) contours.push(cur);
cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
prev = { x: c.x, y: c.y };
} else if (c.type === 'L') {
cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'C') {
cur[cur.length - 1].o = [c.x1, c.y1];
cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'Q') {
const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
cur[cur.length - 1].o = c1;
cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'Z') {
if (cur && cur.length > 1) {
const last = cur[cur.length - 1], first = cur[0];
if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
first.i = last.i; // fold the duplicate closing point into the first
cur.pop();
}
}
if (cur) { contours.push(cur); cur = null; }
}
}
if (cur) contours.push(cur);
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
const out = contours.map(ct => {
const v = [], i = [], o = [];
ct.forEach(pt => {
v.push(pt.v);
i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
});
return { i, o, v, c: true };
});
return { contours: out, bbox: { x: minx, y: miny, w: maxx - minx, h: maxy - miny } };
}
const E = glyphContours('Э');
const D8 = glyphContours('8');
fs.writeFileSync('glyphs.json', JSON.stringify({ em: FS, E, D8 }));
console.log('Э bbox', E.bbox, 'contours', E.contours.map(c => c.v.length));
console.log('8 bbox', D8.bbox, 'contours', D8.contours.map(c => c.v.length));
+153
View File
@@ -0,0 +1,153 @@
'use strict';
// VK preloader Lottie: a flat wooden Erudit tile ("Э" + score "8") that drops in
// under gravity, squashes on impact (convex cushion bulging out the left/right edges
// + a touch of skew), and springs back up — looping. A light "glint" is caught at the
// moment of the bounce. Pure 2D shapes (ddd:0). Glyphs are real LiberationSans
// outlines (Arial-metric, = the game's font stack); see extract.js -> glyphs.json.
const fs = require('fs');
const G = JSON.parse(fs.readFileSync(__dirname + '/glyphs.json', 'utf8'));
// ---- canvas / timing -------------------------------------------------------
const W = 96, H = 96, cx = 48;
const FR = 30, OP = 34; // ~1.13 s loop (dynamic, 25% faster)
// keyframe beats (frames): fast fall -> soft squash -> lift -> fast rise -> apex (no dwell)
const T_HIT = 13, T_PEAK = 18, T_LIFT = 19, T_REC = 28;
// ---- geometry --------------------------------------------------------------
const HW = 26, HH = 26, CR = 6; // tile half-width / half-height / corner radius
const FLOOR = 90, APEX = 60; // bottom-centre screen-y at rest / at the top of the bounce
// ---- palette ---------------------------------------------------------------
const col = h => [parseInt(h.slice(1,3),16)/255, parseInt(h.slice(3,5),16)/255, parseInt(h.slice(5,7),16)/255];
const FACE = col('#D9B978'); // wood face (flat)
const FACE_GLINT = col('#E7CD95'); // face flash caught on impact (gentle, not blinding)
const BORDER = col('#B49559'); // tile rim: a touch darker than the face, reads on any bg
const BLACK = col('#1A1A1A');
const BLACK_LIT = col('#421A0B'); // glyph warms to brown-red on the glint
const lerp = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
// ---- property / easing helpers ---------------------------------------------
const still = v => ({ a: 0, k: v });
const EI = { o: { x: [0.82], y: [0] }, i: { x: [1], y: [1] } }; // strong accelerate (gravity fall)
const EO = { o: { x: [0], y: [0] }, i: { x: [0.18], y: [1] } }; // strong decelerate (rise to apex)
const ES = { o: { x: [0.42], y: [0] }, i: { x: [0.58], y: [1] } }; // soft/slow (the cushion)
function anim(samples) { // samples: {t, v, e?} ; e = easing toward the NEXT key
return { a: 1, k: samples.map((s, idx) => {
const kf = { t: s.t, s: s.v };
if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; }
return kf;
}) };
}
const animColor = s => anim(s.map(x => ({ t: x.t, v: [...x.v, 1], e: x.e })));
// ---- shape helpers ---------------------------------------------------------
const tr = () => ({ ty: 'tr', p: still([0,0]), a: still([0,0]), s: still([100,100]), r: still(0), o: still(100) });
const grp = (items, nm) => ({ ty: 'gr', it: [...items, tr()], nm });
const fill = c => ({ ty: 'fl', c, o: still(100), r: 1, bm: 0 });
const stroke = (c,w) => ({ ty: 'st', c: still(c), o: still(100), w: still(w), lc: 2, lj: 2, ml: 4, bm: 0 });
function glyph(gd, hpx, px, py, bold, colour, nm) { // font glyph -> filled+stroked group
const sc = hpx / gd.bbox.h;
const bcx = gd.bbox.x + gd.bbox.w / 2, bcy = gd.bbox.y + gd.bbox.h / 2;
const shapes = gd.contours.map(ct => ({
ty: 'sh', d: 1, ks: still({
i: ct.i.map(p => [p[0]*sc, p[1]*sc]), o: ct.o.map(p => [p[0]*sc, p[1]*sc]),
v: ct.v.map(p => [(p[0]-bcx)*sc + px, (p[1]-bcy)*sc + py]), c: true,
}),
}));
return grp([...shapes, fill(colour), stroke(BLACK, bold)], nm);
}
// Sample the rest rounded-rect outline (clockwise): fine corner arcs + one mid point
// per straight edge, so a smooth interpolant reproduces it cleanly.
function arc(ccx, ccy, a0, a1, n) {
const out = [];
for (let i = 0; i < n; i++) { const a = (a0 + (a1 - a0) * i / (n - 1)) * Math.PI / 180; out.push([ccx + CR*Math.cos(a), ccy + CR*Math.sin(a)]); }
return out;
}
const REST = (() => {
const NC = 7, yi = HH - CR, xi = HW - CR;
const C = [ arc(xi,-yi,-90,0,NC), arc(xi,yi,0,90,NC), arc(-xi,yi,90,180,NC), arc(-xi,-yi,180,270,NC) ];
const pts = [];
for (let k = 0; k < 4; k++) {
pts.push(...C[k]);
const a = C[k][NC-1], b = C[(k+1)%4][0];
pts.push([(a[0]+b[0])/2, (a[1]+b[1])/2]); // straight-edge mid point (keeps the edge straight)
}
return pts;
})();
// The whole left/right contour (corners + side) bows out by one smooth barrel profile;
// Catmull-Rom tangents (from neighbours) make every junction C1-smooth -> no kink, the
// corners follow the cushion and the cushion melts into the corners, bulged or not.
function facePath(b) {
const f = y => b * (1 - (y/HH) * (y/HH)); // 0 at top/bottom, max at the middle
const V = REST.map(([x, y]) => [x + Math.sign(x) * f(y), y]); // push the sides out, top/bottom stay
const n = V.length, I = [], O = [];
for (let k = 0; k < n; k++) {
const p0 = V[(k-1+n)%n], p1 = V[k], p2 = V[(k+1)%n];
let dx = p2[0]-p0[0], dy = p2[1]-p0[1]; // tangent direction (neighbours)
const dl = Math.hypot(dx, dy) || 1; dx /= dl; dy /= dl;
const ln = Math.hypot(p2[0]-p1[0], p2[1]-p1[1]) / 3; // handles ~ 1/3 of the adjacent chord
const lp = Math.hypot(p1[0]-p0[0], p1[1]-p0[1]) / 3; // -> chordal spline, no overshoot/facets
O.push([dx*ln, dy*ln]); I.push([-dx*lp, -dy*lp]);
}
return { i: I, o: O, v: V, c: true };
}
const facePathKeys = samples => ({ a: 1, k: samples.map((s, idx) => {
const kf = { t: s.t, s: [facePath(s.b)] };
if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; }
return kf;
}) });
// ---- animation tracks ------------------------------------------------------
// bottom-centre screen-y (the tile rests/squashes on its bottom edge)
const posY = anim([
{ t: 0, v: [cx, APEX, 0], e: EI }, // apex -> immediately falls (no dwell)
{ t: T_HIT, v: [cx, FLOOR, 0], e: ES }, // FAST fall (accelerate) -> floor
{ t: T_LIFT, v: [cx, FLOOR, 0], e: EO }, // sit on the floor while the cushion absorbs
{ t: OP, v: [cx, APEX, 0] }, // FAST rise (decelerate) -> apex = loop start
]);
// scale about the bottom anchor: stretch while falling, gentle/slow cushion squash on impact
const scl = anim([
{ t: 0, v: [100, 100, 100], e: ES },
{ t: T_HIT-5, v: [95, 106, 100], e: ES }, // stretch while falling fast
{ t: T_HIT, v: [104, 95, 100], e: ES }, // touch down
{ t: T_PEAK, v: [112, 86, 100], e: ES }, // soft squash peak (gentler, less plче)
{ t: T_REC, v: [100, 100, 100], e: ES }, // slow cushion release -> soft landing
{ t: OP, v: [100, 100, 100] },
]);
// a touch of skew through the squash (organic deform), back to 0
const skw = anim([
{ t: T_HIT, v: [0], e: ES }, { t: T_PEAK, v: [4], e: ES }, { t: T_REC, v: [0] }, { t: OP, v: [0] },
]);
// left/right cushion bulge: 0 -> gentle peak -> 0 (never inward)
const bulge = facePathKeys([
{ t: T_HIT, b: 0, e: ES }, { t: T_PEAK, b: 4, e: ES }, { t: T_REC, b: 0 }, { t: OP, b: 0 },
]);
// glint: face + glyph catch the light at the bounce
const faceCol = animColor([
{ t: T_HIT-2, v: FACE, e: ES }, { t: T_PEAK, v: FACE_GLINT, e: ES }, { t: T_REC, v: FACE }, { t: OP, v: FACE },
]);
const glyphCol = animColor([
{ t: T_HIT-2, v: BLACK, e: ES }, { t: T_PEAK, v: BLACK_LIT, e: ES }, { t: T_REC, v: BLACK }, { t: OP, v: BLACK },
]);
// ---- layer (face + glyphs share the bounce/squash transform) ---------------
const faceShape = grp([ { ty: 'sh', d: 1, ks: bulge }, fill(faceCol), stroke(BORDER, 1.8) ], 'face');
const glyphE = glyph(G.E, 32, 0, -1, 1.0, glyphCol, 'E'); // centred Э
const glyph8 = glyph(G.D8, 8.5, HW-6.5, HH-7.5, 0.5, glyphCol, 'score'); // 8 in the corner
const tile = {
ddd: 0, ind: 1, ty: 4, nm: 'tile', sr: 1,
ks: { o: still(100), r: still(0), p: posY, a: still([0, HH, 0]), s: scl, sk: skw, sa: still(0) },
ao: 0, shapes: [ glyph8, glyphE, faceShape ], ip: 0, op: OP, st: 0, bm: 0,
};
const root = {
v: '5.7.4', fr: FR, ip: 0, op: OP, w: W, h: H, nm: 'erudit-vk-loader', ddd: 0,
assets: [], layers: [ tile ], markers: [],
};
const out = process.argv[2] || 'erudit.json';
const round = (k, v) => (typeof v === 'number' ? Math.round(v * 100) / 100 : v);
fs.writeFileSync(out, JSON.stringify(root, round));
console.log('wrote', out, fs.statSync(out).size, 'bytes');
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -7,12 +7,14 @@
# (GOPRIVATE), so the build stage needs git and network. # (GOPRIVATE), so the build stage needs git and network.
# #
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all # Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
# in the Docker context: # in the Docker context. DICT_VERSION has no default — the caller supplies the
# docker build -f backend/Dockerfile -t scrabble-backend . # scrabble-dictionary release tag (compose/CI pass it; see deploy/README.md
# "Bumping the dictionary version"):
# docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend .
# --- dictionary artifact ----------------------------------------------------- # --- dictionary artifact -----------------------------------------------------
FROM alpine:3.20 AS dawg FROM alpine:3.20 AS dawg
ARG DICT_VERSION=v1.2.1 ARG DICT_VERSION
RUN apk add --no-cache curl tar RUN apk add --no-cache curl tar
RUN mkdir -p /dawg \ RUN mkdir -p /dawg \
&& curl -fsSL -o /tmp/dawg.tar.gz \ && curl -fsSL -o /tmp/dawg.tar.gz \
@@ -42,7 +44,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
# Re-declare the build arg in this stage so it labels the seed dictionary. One # Re-declare the build arg in this stage so it labels the seed dictionary. One
# DICT_VERSION drives both the artifact the dawg stage downloads and the version # DICT_VERSION drives both the artifact the dawg stage downloads and the version
# label the binary pins, so the resident version equals the release tag. # label the binary pins, so the resident version equals the release tag.
ARG DICT_VERSION=v1.2.1 ARG DICT_VERSION
COPY --from=build /out/backend /usr/local/bin/backend COPY --from=build /out/backend /usr/local/bin/backend
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume # Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
# mounted at /opt/dawg inherits this ownership on first use, so the admin console # mounted at /opt/dawg inherits this ownership on first use, so the admin console
+12 -8
View File
@@ -43,8 +43,10 @@ per-user blocks, and per-game chat with nudges folded in as a message kind; chat
messages are length-capped, content-filtered (no links/emails/phone numbers, messages are length-capped, content-filtered (no links/emails/phone numbers,
including obfuscated forms) and stored with the sender's IP. Each message carries an including obfuscated forms) and stored with the sender's IP. Each message carries an
`unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead` `unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead`
clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer` clears a reader's bit when they open the move history or chat, a wired `NudgeClearer`
clears a nudge when its recipient moves — both record the publish-to-read latency. clears a nudge when its recipient moves, and a wired `NudgeExpirer` clears **all** of a game's
nudges when it finishes (any completion path) — the first two record the publish-to-read latency,
the completion expiry does not (it is not a read); chat messages stay unread on completion.
A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat
in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a
background reaper drops a robot friend request once its game has been finished for **7 days**. background reaper drops a robot friend request once its game has been finished for **7 days**.
@@ -212,11 +214,13 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. | | `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
| `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. | | `BACKEND_LOBBY_REAPER_INTERVAL` | `1s` | How often the substitution reaper scans for over-waited players. |
| `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. | | `BACKEND_ROBOT_DRIVE_INTERVAL` | `30s` | How often the robot driver scans for due robot turns. |
| `BACKEND_SMTP_HOST` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). | | `BACKEND_SMTP_HOST` | — | Confirm-code relay host. **Empty selects the development log mailer** (the code is logged, not sent). |
| `BACKEND_SMTP_PORT` | `587` | Email relay port. | | `BACKEND_SMTP_PORT` | `587` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. | | `BACKEND_SMTP_TLS` | — | Transport security: `ssl` (implicit TLS from connect) or `starttls`. Empty derives it from the port (implicit on `465`, STARTTLS otherwise); set it for a relay on a non-standard port (e.g. Selectel's `1127` = SSL, `1126` = STARTTLS). |
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. | | `BACKEND_SMTP_USERNAME` | — | SMTP AUTH user; empty relays without authentication. |
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. | | `BACKEND_SMTP_PASSWORD` | — | SMTP AUTH password. |
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | From address. A deployed contour must use the prod domain (the relay only accepts its verified sender domain). |
| `BACKEND_PUBLIC_BASE_URL` | — | Canonical public origin (scheme + host) for links in the email. **Required when `BACKEND_SMTP_HOST` is set.** Never derived from a request Host header (anti-injection). |
| `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. | | `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. | | `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. | | `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
@@ -228,7 +232,7 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
```sh ```sh
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg): # DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.2.1/scrabble-dawg-v1.2.1.tar.gz | tar xz -C /tmp/dawg mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.3.0/scrabble-dawg-v1.3.0.tar.gz | tar xz -C /tmp/dawg
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \ BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
BACKEND_DICT_DIR=/tmp/dawg \ BACKEND_DICT_DIR=/tmp/dawg \
GOPRIVATE='gitea.iliadenisov.ru/*' \ GOPRIVATE='gitea.iliadenisov.ru/*' \
+43 -4
View File
@@ -3,8 +3,8 @@
// loads the dictionaries into the engine registry, warms the session cache, // loads the dictionaries into the engine registry, warms the session cache,
// constructs the game domain and starts its turn-timeout sweeper, constructs the // constructs the game domain and starts its turn-timeout sweeper, constructs the
// lobby and social domains, then serves the HTTP listener with the infrastructure // lobby and social domains, then serves the HTTP listener with the infrastructure
// probes and the /api/v1 route-group skeleton. Domain HTTP endpoints are added // probes and the /api/v1 route group, behind which the domains expose their HTTP
// with the gateway in a later stage described in PLAN.md. // endpoints to the gateway.
package main package main
import ( import (
@@ -20,6 +20,7 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge" "scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/adminalert"
"scrabble/backend/internal/ads" "scrabble/backend/internal/ads"
"scrabble/backend/internal/banview" "scrabble/backend/internal/banview"
"scrabble/backend/internal/config" "scrabble/backend/internal/config"
@@ -33,6 +34,7 @@ import (
"scrabble/backend/internal/postgres" "scrabble/backend/internal/postgres"
"scrabble/backend/internal/pushgrpc" "scrabble/backend/internal/pushgrpc"
"scrabble/backend/internal/ratewatch" "scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/render"
"scrabble/backend/internal/robot" "scrabble/backend/internal/robot"
"scrabble/backend/internal/server" "scrabble/backend/internal/server"
"scrabble/backend/internal/session" "scrabble/backend/internal/session"
@@ -43,6 +45,10 @@ import (
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit. // telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
const telemetryShutdownTimeout = 5 * time.Second const telemetryShutdownTimeout = 5 * time.Second
// adminAlertInterval is how often the operator-alert worker checks for new feedback /
// complaints; a burst within one interval coalesces into a single digest email.
const adminAlertInterval = 5 * time.Minute
func main() { func main() {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
@@ -161,6 +167,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.Duration("interval", cfg.GuestReapInterval), zap.Duration("interval", cfg.GuestReapInterval),
zap.Duration("retention", cfg.GuestRetention)) zap.Duration("retention", cfg.GuestRetention))
// Purge the account-deletion legal dossier past its retention TTL: the
// retained-identities journal, and the feedback thread + dossier PII of long-deleted
// accounts (chat is kept). Checked daily; the TTL is a two-year policy constant.
retentionReaper := account.NewRetentionReaper(accounts, account.RetentionTTL, logger)
go retentionReaper.Run(ctx, 24*time.Hour)
logger.Info("retention reaper started",
zap.Duration("interval", 24*time.Hour),
zap.Duration("retention", account.RetentionTTL))
// Re-evaluate moderated-chat write access when a temporary block self-expires: // Re-evaluate moderated-chat write access when a temporary block self-expires:
// no operator action fires then, so the sweeper emits the chat-access-changed // no operator action fires then, so the sweeper emits the chat-access-changed
// event for lapsed blocks and the gateway re-pushes the chat-gate command. // event for lapsed blocks and the gateway re-pushes the chat-gate command.
@@ -173,15 +188,20 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// Lobby & social domains. Their REST and stream surface lives in the gateway, // Lobby & social domains. Their REST and stream surface lives in the gateway,
// so they are handed to the server (like the route groups) for the handlers. // so they are handed to the server (like the route groups) for the handlers.
mailer := newMailer(cfg.SMTP, logger) mailer := newMailer(cfg.SMTP, logger)
emails := account.NewEmailService(accounts, mailer) emails := account.NewEmailService(accounts, mailer, cfg.PublicBaseURL)
// Throttle confirm-code sends per recipient: at most one per minute and five per
// rolling hour, guarding against email bombing and the relay's own quota.
emails.SetSendLimiter(account.NewSendLimiter(time.Minute, 5))
// Account linking & merge: the orchestrator over the account, merge and // Account linking & merge: the orchestrator over the account, merge and
// session layers. Wired to the /api/v1/user/link REST surface below. // session layers. Wired to the /api/v1/user/link REST surface below.
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions) links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
socialSvc := social.NewService(social.NewStore(db), accounts, games) socialSvc := social.NewService(social.NewStore(db), accounts, games)
socialSvc.SetNotifier(hub) socialSvc.SetNotifier(hub)
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social")) socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
// A nudge the recipient answered by moving is marked read on the move path. // A nudge the recipient answered by moving is marked read on the move path; every nudge in a
// game is marked read when the game finishes (a stale badge), on any completion path.
games.SetNudgeClearer(socialSvc.ClearNudges) games.SetNudgeClearer(socialSvc.ClearNudges)
games.SetNudgeExpirer(socialSvc.ExpireNudges)
// Reap per-game disguised-robot friend requests once their game is long finished // Reap per-game disguised-robot friend requests once their game is long finished
// (the robot ignores them; the row only pins the in-game "request sent" state). // (the robot ignores them; the row only pins the in-game "request sent" state).
robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger) robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger)
@@ -192,6 +212,16 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts) feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
feedbackSvc.SetNotifier(hub) feedbackSvc.SetNotifier(hub)
// Operator alert emails on new feedback / word complaints, coalesced into one digest
// per interval. Inert unless a distinct admin sender and recipient are configured.
if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" {
// The alert digest carries no admin-console link on purpose — an admin URL must not
// travel in an email (a mail provider could cache or index it); see adminalert.New.
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, logger)
go alerts.Run(ctx, adminAlertInterval)
logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval))
}
// Robot opponent: provision its durable account pool (a hard startup // Robot opponent: provision its durable account pool (a hard startup
// dependency, like the dictionaries) and start its move driver. The matchmaker // dependency, like the dictionaries) and start its move driver. The matchmaker
// substitutes a pooled robot for a missing human after the wait window. // substitutes a pooled robot for a missing human after the wait window.
@@ -230,6 +260,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// block and the banner admin console section. // block and the banner admin console section.
adsSvc := ads.NewService(ads.NewStore(db)) adsSvc := ads.NewService(ads.NewStore(db))
// The image-render sidecar client for the PNG export artifact; nil (PNG
// download answers 404) when BACKEND_RENDERER_URL is unset.
var renderer *render.Client
if cfg.RendererURL != "" {
renderer = render.New(cfg.RendererURL)
}
srv := server.New(cfg.HTTPAddr, server.Deps{ srv := server.New(cfg.HTTPAddr, server.Deps{
Logger: logger, Logger: logger,
DB: db, DB: db,
@@ -251,6 +288,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
BanView: banView, BanView: banView,
Ads: adsSvc, Ads: adsSvc,
Notifier: hub, Notifier: hub,
ExportSignKey: cfg.ExportSignKey,
Renderer: renderer,
}) })
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger) pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
+193
View File
@@ -0,0 +1,193 @@
// Command dictgen dumps golden parity vectors from the committed dawg
// dictionaries so the TypeScript dawg reader can be checked byte-for-byte
// against the authoritative Go dafsa reader.
//
// For each *.dawg file it writes, into the output directory:
//
// - <name>.words.bin — every stored word as alphabet-index bytes, in index
// order, framed as [1-byte length][length index bytes]. The word at stream
// position k has IndexOfB == k.
// - <name>.neg.bin — negative lookups (sequences whose IndexOfB is -1), same
// framing, to exercise the not-found path at varying depths.
// - <name>.meta.json — NumAdded/NumNodes/NumEdges plus the alphabet size, for
// a header-parse sanity cross-check on the TS side.
//
// It is a development tool (not built into any service), analogous to
// cmd/jetgen. Run it from the repository root:
//
// go run ./backend/cmd/dictgen -dawg-dir ../scrabble-solver/dawg -out <dir>
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"math/rand"
"os"
"path/filepath"
"sort"
"strings"
dawg "github.com/iliadenisov/dafsa"
)
// meta is the per-dictionary sanity payload cross-checked by the TS reader.
type meta struct {
NumAdded int `json:"numAdded"`
NumNodes int `json:"numNodes"`
NumEdges int `json:"numEdges"`
Alphabet int `json:"alphabet"`
}
func main() {
dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
outDir := flag.String("out", "", "output directory for the golden files (required)")
negCount := flag.Int("neg", 20000, "number of negative lookups to emit per dictionary")
flag.Parse()
if *outDir == "" {
fail("-out is required")
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
fail("mkdir out: %v", err)
}
files, err := filepath.Glob(filepath.Join(*dawgDir, "*.dawg"))
if err != nil {
fail("glob: %v", err)
}
sort.Strings(files)
if len(files) == 0 {
fail("no .dawg files in %s", *dawgDir)
}
for _, f := range files {
if err := process(f, *outDir, *negCount); err != nil {
fail("%s: %v", filepath.Base(f), err)
}
}
}
// process emits the golden files for a single dawg dictionary.
func process(path, outDir string, negCount int) error {
name := strings.TrimSuffix(filepath.Base(path), ".dawg")
data, err := os.ReadFile(path)
if err != nil {
return err
}
finder, err := dawg.Read(bytes.NewReader(data), 0)
if err != nil {
return fmt.Errorf("read dawg: %w", err)
}
defer finder.Close()
// Stream every stored word in index order; keep a decimated sample and the
// maximum alphabet index for negative generation.
wf, err := os.Create(filepath.Join(outDir, name+".words.bin"))
if err != nil {
return err
}
bw := bufio.NewWriter(wf)
var (
count int
maxIx byte
sample [][]byte
)
finder.EnumerateB(func(index int, word []byte, final bool) int {
if !final {
return 0 // Continue
}
if index != count {
panic(fmt.Sprintf("%s: enumerate index gap: got %d want %d", name, index, count))
}
writeWord(bw, word)
for _, b := range word {
if b > maxIx {
maxIx = b
}
}
if count%4 == 0 && len(sample) < 60000 {
sample = append(sample, append([]byte(nil), word...))
}
count++
return 0 // Continue
})
if err := bw.Flush(); err != nil {
return err
}
if err := wf.Close(); err != nil {
return err
}
if count != finder.NumAdded() {
return fmt.Errorf("word count %d != NumAdded %d", count, finder.NumAdded())
}
alphabet := int(maxIx) + 1
// Negatives: mutate sampled real words and keep the ones the reader rejects.
nf, err := os.Create(filepath.Join(outDir, name+".neg.bin"))
if err != nil {
return err
}
nbw := bufio.NewWriter(nf)
rng := rand.New(rand.NewSource(1))
neg := 0
for neg < negCount && len(sample) > 0 {
base := sample[rng.Intn(len(sample))]
cand := append([]byte(nil), base...)
switch rng.Intn(3) {
case 0: // extend by one index
cand = append(cand, byte(rng.Intn(alphabet)))
case 1: // flip one index
if len(cand) > 0 {
cand[rng.Intn(len(cand))] = byte(rng.Intn(alphabet))
}
case 2: // drop the tail and flip the new last index
if len(cand) > 1 {
cand = cand[:len(cand)-1]
cand[len(cand)-1] = byte(rng.Intn(alphabet))
}
}
if finder.IndexOfB(cand) == -1 {
writeWord(nbw, cand)
neg++
}
}
if err := nbw.Flush(); err != nil {
return err
}
if err := nf.Close(); err != nil {
return err
}
m := meta{NumAdded: finder.NumAdded(), NumNodes: finder.NumNodes(), NumEdges: finder.NumEdges(), Alphabet: alphabet}
mb, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(outDir, name+".meta.json"), mb, 0o644); err != nil {
return err
}
fmt.Printf("%-12s words=%d negatives=%d alphabet=%d nodes=%d edges=%d\n",
name, count, neg, alphabet, finder.NumNodes(), finder.NumEdges())
return nil
}
// writeWord frames one index-byte word as [length][bytes].
func writeWord(w *bufio.Writer, word []byte) {
if len(word) > 255 {
panic(fmt.Sprintf("word too long to frame: %d", len(word)))
}
w.WriteByte(byte(len(word)))
w.Write(word)
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "dictgen: "+format+"\n", args...)
os.Exit(1)
}
+486
View File
@@ -0,0 +1,486 @@
// Command validategen produces golden conformance fixtures for the TypeScript
// move validator (ui/src/lib/dict/validate.ts). For each variant it self-plays
// greedy games with the authoritative scrabble-solver engine to build realistic
// board positions, then records a battery of candidate plays — the engine's own
// top move, letter-mutated variants, random scatters and (on the empty board) an
// off-centre translation — each paired with the ground-truth result of
// ValidatePlayOpts (legal, score, the words formed). The TS conformance test
// replays these and must agree exactly.
//
// It is a development tool (not built into any service), analogous to
// cmd/dictgen. Run it from the repository root:
//
// go run ./backend/cmd/validategen -dawg-dir ../scrabble-solver/dawg -out <dir>
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"math/rand"
"os"
"path/filepath"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/selfplay"
dawg "github.com/iliadenisov/dafsa"
)
// blankTile marks a blank tile in a drawn hand (matches selfplay).
const blankTile byte = 0xff
// variantSpec pairs a variant label with its ruleset and dawg file.
type variantSpec struct {
name string
rules *rules.Ruleset
dawg string
}
// cell is an occupied board square or a placement (alphabet-index letter).
type cell struct {
R, C, Letter int
Blank bool
}
// word mirrors scrabble.Word in index space.
type word struct {
Row, Col, Dir int
Letters []int
Blanks []bool
Score int
}
// fixture is one candidate play with the engine's ground-truth verdict.
type fixture struct {
Board int `json:"board"` // index into the boards list
Dir int `json:"dir"`
IgnoreCrossWords bool `json:"ignoreCrossWords"`
Tiles []cell `json:"tiles"`
Legal bool `json:"legal"`
Score int `json:"score"`
Bonus int `json:"bonus"`
Main *word `json:"main,omitempty"`
Cross []word `json:"cross,omitempty"`
}
// alphaEntry mirrors one row of the per-variant alphabet table the server sends the
// client (index, concrete letter as the ruleset emits it, tile value), so the adapter
// cross-test can drive the letter-space client path exactly as production does.
type alphaEntry struct {
Index int `json:"index"`
Letter string `json:"letter"`
Value int `json:"value"`
}
// variantFile is the whole conformance payload for one variant.
type variantFile struct {
Variant string `json:"variant"`
Rows int `json:"rows"`
Cols int `json:"cols"`
Center int `json:"center"`
RackSize int `json:"rackSize"`
Bingo int `json:"bingo"`
Values []int `json:"values"`
Premiums []int `json:"premiums"` // row-major rules.Premium codes
Alphabet []alphaEntry `json:"alphabet"`
Boards [][]cell `json:"boards"`
Fixtures []fixture `json:"fixtures"`
}
func main() {
dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
outDir := flag.String("out", "", "output directory for the fixture files (required)")
games := flag.Int("games", 6, "self-play games per (variant, rule)")
plies := flag.Int("plies", 40, "maximum plies captured per game")
flag.Parse()
if *outDir == "" {
fail("-out is required")
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
fail("mkdir out: %v", err)
}
specs := []variantSpec{
{"scrabble_en", rules.English(), "en_sowpods.dawg"},
{"scrabble_ru", rules.RussianScrabble(), "ru_scrabble.dawg"},
{"erudit_ru", rules.Erudit(), "ru_erudit.dawg"},
}
for _, sp := range specs {
if err := generate(sp, *dawgDir, *outDir, *games, *plies); err != nil {
fail("%s: %v", sp.name, err)
}
}
}
func generate(sp variantSpec, dawgDir, outDir string, games, plies int) error {
data, err := os.ReadFile(filepath.Join(dawgDir, sp.dawg))
if err != nil {
return err
}
finder, err := dawg.Read(bytes.NewReader(data), 0)
if err != nil {
return fmt.Errorf("read dawg: %w", err)
}
defer finder.Close()
rs := sp.rules
solver := scrabble.NewSolver(rs, finder)
out := variantFile{
Variant: sp.name, Rows: rs.Rows, Cols: rs.Cols, Center: rs.Center,
RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values,
Premiums: premiumCodes(rs), Alphabet: alphabetOf(rs),
}
// Capture under both the standard rule and the single-word rule, building the
// board with the same rule so positions are reachable under it.
for _, ignore := range []bool{false, true} {
opts := scrabble.PlayOptions{IgnoreCrossWords: ignore}
for g := range games {
seed := int64(g*1000) + boolseed(ignore) + variantSeed(sp.name)
playAndCapture(&out, rs, solver, opts, seed, plies)
}
}
b, err := json.Marshal(&out)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(outDir, sp.name+".fixtures.json"), b, 0o644); err != nil {
return err
}
fmt.Printf("%-12s boards=%d fixtures=%d\n", sp.name, len(out.Boards), len(out.Fixtures))
return nil
}
// playAndCapture greedily self-plays one game, recording candidate plays against
// each board position along the way.
func playAndCapture(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, seed int64, plies int) {
rng := rand.New(rand.NewSource(seed))
bag := selfplay.NewBag(rs, seed)
b := board.New(rs.Rows, rs.Cols)
hands := [2][]byte{bag.Draw(rs.RackSize), bag.Draw(rs.RackSize)}
passes := 0
for turn := range plies {
p := turn % 2
rk := rackOf(hands[p], rs.Size())
moves := solver.GenerateMovesOpts(b, rk, scrabble.Both, opts)
if len(moves) == 0 {
if passes++; passes >= 4 {
break
}
continue
}
passes = 0
top := moves[0]
boardIdx := len(out.Boards)
out.Boards = append(out.Boards, boardCells(b))
captureCandidates(out, rs, solver, opts, b, boardIdx, top, rng)
scrabble.Apply(b, top)
hands[p] = removeUsed(hands[p], top)
if need := rs.RackSize - len(hands[p]); need > 0 {
hands[p] = append(hands[p], bag.Draw(need)...)
}
if len(hands[p]) == 0 && bag.Len() == 0 {
break
}
}
}
// captureCandidates records the engine's top move plus derived candidates for one
// board, each with its ValidatePlayOpts verdict.
func captureCandidates(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, top scrabble.Move, rng *rand.Rand) {
size := rs.Size()
record := func(tiles []scrabble.Placement) {
if len(tiles) == 0 {
return
}
out.Fixtures = append(out.Fixtures, makeFixture(solver, opts, b, boardIdx, tiles))
}
// The engine's own top move (legal).
record(top.Tiles)
// Letter-mutated variants: usually reject on the dictionary, occasionally form
// a different legal word.
for range 3 {
mut := clonePlacements(top.Tiles)
i := rng.Intn(len(mut))
mut[i].Letter = byte((int(mut[i].Letter) + 1 + rng.Intn(size-1)) % size)
record(mut)
}
// Random scatters: exercise geometry, dictionary and connectivity paths.
for range 3 {
record(randomScatter(b, size, 2+rng.Intn(4), rng))
}
// Single tiles abutting the board exercise the direction inference — a single
// tile is ambiguous, its orientation resolved from which axis it extends.
for range 3 {
if t, ok := randomAdjacentSingle(b, size, rng); ok {
record([]scrabble.Placement{t})
}
}
// On the empty board, an off-centre translation of the first move exercises the
// first-move centre rule.
if b.IsEmpty() {
shifted := clonePlacements(top.Tiles)
ok := true
for i := range shifted {
shifted[i].Row++
shifted[i].Col++
if !b.InBounds(shifted[i].Row, shifted[i].Col) {
ok = false
break
}
}
if ok {
record(shifted)
}
}
}
// makeFixture validates a candidate against board b and serializes it with its
// ground truth. Word breakdown is recorded only for legal plays (the TS test
// checks words only then); an illegal play records legal=false alone.
func makeFixture(solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, tiles []scrabble.Placement) fixture {
// Infer the orientation exactly as the backend evaluate does (dir-less), so the
// fixture matches the real eval path and pins the client's ported inference.
dir := playDirectionMirror(solver, b, tiles, opts)
fx := fixture{
Board: boardIdx,
Dir: int(dir),
IgnoreCrossWords: opts.IgnoreCrossWords,
Tiles: placementCells(tiles),
}
m, err := solver.ValidatePlayOpts(b, dir, tiles, opts)
if err == nil {
fx.Legal = true
fx.Score = m.Score
fx.Bonus = m.Bonus
fx.Main = toWord(m.Main)
for _, cw := range m.Cross {
fx.Cross = append(fx.Cross, *toWord(cw))
}
}
return fx
}
func placementCells(ts []scrabble.Placement) []cell {
cs := make([]cell, len(ts))
for i, t := range ts {
cs[i] = cell{R: t.Row, C: t.Col, Letter: int(t.Letter), Blank: t.Blank}
}
return cs
}
func toWord(w scrabble.Word) *word {
letters := make([]int, len(w.Letters))
for i, l := range w.Letters {
letters[i] = int(l)
}
return &word{
Row: w.Row, Col: w.Col, Dir: int(w.Dir),
Letters: letters, Blanks: append([]bool(nil), w.Blanks...), Score: w.Score,
}
}
func alphabetOf(rs *rules.Ruleset) []alphaEntry {
n := rs.Alphabet.Size()
out := make([]alphaEntry, n)
for i := range n {
ch, _ := rs.Alphabet.Character(byte(i))
out[i] = alphaEntry{Index: i, Letter: ch, Value: rs.Values[i]}
}
return out
}
func premiumCodes(rs *rules.Ruleset) []int {
codes := make([]int, rs.Rows*rs.Cols)
for i := range codes {
codes[i] = int(rs.PremiumAt(i))
}
return codes
}
func boardCells(b *board.Board) []cell {
var cs []cell
for r := 0; r < b.Rows(); r++ {
for c := 0; c < b.Cols(); c++ {
if b.Filled(r, c) {
v := b.At(r, c)
cs = append(cs, cell{R: r, C: c, Letter: int(v&0x3f) - 1, Blank: v&0x80 != 0})
}
}
}
return cs
}
func clonePlacements(ts []scrabble.Placement) []scrabble.Placement {
return append([]scrabble.Placement(nil), ts...)
}
// randomScatter picks n distinct empty in-bounds squares with random letters.
func randomScatter(b *board.Board, size, n int, rng *rand.Rand) []scrabble.Placement {
seen := map[[2]int]bool{}
var ts []scrabble.Placement
for tries := 0; tries < n*20 && len(ts) < n; tries++ {
r := rng.Intn(b.Rows())
c := rng.Intn(b.Cols())
if seen[[2]int{r, c}] || b.Filled(r, c) {
continue
}
seen[[2]int{r, c}] = true
ts = append(ts, scrabble.Placement{Row: r, Col: c, Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0})
}
return ts
}
// randomAdjacentSingle picks a random empty in-bounds square abutting at least one
// filled square, with a random letter — a single-tile play whose orientation the
// inference must resolve. It returns ok=false on an empty board.
func randomAdjacentSingle(b *board.Board, size int, rng *rand.Rand) (scrabble.Placement, bool) {
var cands [][2]int
for r := 0; r < b.Rows(); r++ {
for c := 0; c < b.Cols(); c++ {
if b.Filled(r, c) {
continue
}
if b.Filled(r-1, c) || b.Filled(r+1, c) || b.Filled(r, c-1) || b.Filled(r, c+1) {
cands = append(cands, [2]int{r, c})
}
}
}
if len(cands) == 0 {
return scrabble.Placement{}, false
}
rc := cands[rng.Intn(len(cands))]
return scrabble.Placement{Row: rc[0], Col: rc[1], Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0}, true
}
// playDirectionMirror mirrors engine (*Game).playDirection: the geometric
// resolution, except a single tile under the single-word rule tries both
// orientations through the solver and keeps the higher-scoring legal one (H wins
// ties). It reproduces the orientation the backend evaluate infers.
func playDirectionMirror(solver *scrabble.Solver, b *board.Board, placements []scrabble.Placement, opts scrabble.PlayOptions) scrabble.Direction {
geo := resolveDirectionMirror(b, placements)
if len(placements) != 1 || !opts.IgnoreCrossWords {
return geo
}
best, found, bestScore := geo, false, 0
for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} {
m, err := solver.ValidatePlayOpts(b, dir, placements, opts)
if err != nil {
continue
}
if !found || m.Score > bestScore {
best, found, bestScore = dir, true, m.Score
}
}
return best
}
// resolveDirectionMirror mirrors engine.resolveDirection.
func resolveDirectionMirror(b *board.Board, placements []scrabble.Placement) scrabble.Direction {
if len(placements) >= 2 {
row := placements[0].Row
for _, p := range placements[1:] {
if p.Row != row {
return scrabble.Vertical
}
}
return scrabble.Horizontal
}
if len(placements) == 1 {
p := placements[0]
h := runLengthMirror(b, p.Row, p.Col, scrabble.Horizontal)
v := runLengthMirror(b, p.Row, p.Col, scrabble.Vertical)
if v >= 2 && v > h {
return scrabble.Vertical
}
if h >= 2 {
return scrabble.Horizontal
}
if v >= 2 {
return scrabble.Vertical
}
}
return scrabble.Horizontal
}
// runLengthMirror mirrors engine.runLength.
func runLengthMirror(b *board.Board, row, col int, dir scrabble.Direction) int {
dr, dc := 0, 1
if dir == scrabble.Vertical {
dr, dc = 1, 0
}
n := 1
for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc {
n++
}
for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc {
n++
}
return n
}
// rackOf builds a generation rack from a hand of tiles (reimplemented from the
// unexported selfplay helper).
func rackOf(tiles []byte, size int) rack.Rack {
r := rack.New(size)
for _, t := range tiles {
if t == blankTile {
r.AddBlank()
} else {
r.Add(t)
}
}
return r
}
// removeUsed returns the hand with the tiles consumed by m removed.
func removeUsed(tiles []byte, m scrabble.Move) []byte {
out := append([]byte(nil), tiles...)
for _, p := range m.Tiles {
want := p.Letter
if p.Blank {
want = blankTile
}
for i, t := range out {
if t == want {
out = append(out[:i], out[i+1:]...)
break
}
}
}
return out
}
func boolseed(b bool) int64 {
if b {
return 500000
}
return 0
}
func variantSeed(name string) int64 {
var s int64
for _, r := range name {
s = s*131 + int64(r)
}
return s
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "validategen: "+format+"\n", args...)
os.Exit(1)
}
+2 -1
View File
@@ -13,6 +13,7 @@ require (
github.com/pressly/goose/v3 v3.27.1 github.com/pressly/goose/v3 v3.27.1
github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
github.com/wneessen/go-mail v0.7.3
go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
@@ -109,7 +110,7 @@ require (
golang.org/x/net v0.53.0 // indirect golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.37.0 // indirect
google.golang.org/grpc v1.80.0 google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
+4
View File
@@ -279,6 +279,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
@@ -400,6 +402,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+144 -24
View File
@@ -22,12 +22,13 @@ import (
"scrabble/backend/internal/postgres/jet/backend/table" "scrabble/backend/internal/postgres/jet/backend/table"
) )
// Identity kinds recognised by the backend. Email is modelled as an identity // Identity kinds recognised by the backend. Telegram and VK are platform identities,
// alongside platform identities; its confirmed flag is driven by the email // auto-confirmed on first contact. Email is modelled as an identity alongside them; its
// confirm-code flow. Robot is a synthetic kind: each pooled // confirmed flag is driven by the email confirm-code flow. Robot is a synthetic kind:
// robot opponent is a durable account bound to one robot identity. // each pooled robot opponent is a durable account bound to one robot identity.
const ( const (
KindTelegram = "telegram" KindTelegram = "telegram"
KindVK = "vk"
KindEmail = "email" KindEmail = "email"
KindRobot = "robot" KindRobot = "robot"
) )
@@ -119,6 +120,47 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
return s.provision(ctx, kind, externalID, provisionSeed{}) return s.provision(ctx, kind, externalID, provisionSeed{})
} }
// ProvisionEmail returns the account owning the email identity externalID, creating
// it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset —
// seeded into its time zone, language seeded from the client's UI language, and its
// display name seeded from the email's local part (so it is not left nameless). Like
// ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a
// returning user's saved zone, language and name are never overwritten. The email account is
// created here (the code-request step), not at the later login, so this is where its
// zone and language are seeded. It is created flagged is_guest with an unconfirmed
// email identity: an abandoned, never-confirmed login is then reaped like any guest,
// freeing the reserved address, and confirming the code clears the guest flag.
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ, language string) (Account, error) {
return s.provision(ctx, KindEmail, externalID, provisionSeed{
displayName: emailDisplayName(externalID),
timeZone: seedZone(browserTZ),
preferredLanguage: supportedLanguage(language),
isGuest: true,
})
}
// emailDisplayName derives a display name from an email address — the local part
// before '@', trimmed and capped to the column width — so a new email account is not
// left nameless. It is only the first-contact seed; the user can rename it later.
func emailDisplayName(email string) string {
local, _, _ := strings.Cut(email, "@")
local = strings.TrimSpace(local)
if r := []rune(local); len(r) > maxDisplayName {
local = strings.TrimRight(string(r[:maxDisplayName]), " ")
}
return local
}
// supportedLanguage returns code normalised to a supported UI language ("en" or
// "ru"), or "" when it maps to neither, so a new account keeps the 'en' default. It
// accepts region-tagged codes ("ru-RU").
func supportedLanguage(code string) string {
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(code)), "-"); lang == "en" || lang == "ru" {
return lang
}
return ""
}
// ProvisionRobot provisions (or finds) the durable account backing a robot pool // ProvisionRobot provisions (or finds) the durable account backing a robot pool
// member: a KindRobot identity carrying displayName, with chat blocked but friend // member: a KindRobot identity carrying displayName, with chat blocked but friend
// requests NOT blocked — a request to a robot is accepted as pending and, since the // requests NOT blocked — a request to a robot is accepted as pending and, since the
@@ -160,7 +202,7 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri
// is never overwritten. The created flag lets the auth handler re-evaluate moderated- // is never overwritten. The created flag lets the auth handler re-evaluate moderated-
// chat write access on first registration — the path of a user who joined the chat // chat write access on first registration — the path of a user who joined the chat
// before registering, whom no chat_member event covers. // before registering, whom no chat_member event covers.
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, bool, error) { func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName, browserTZ string) (Account, bool, error) {
// Pre-check whether the identity already exists so the caller can act on first // Pre-check whether the identity already exists so the caller can act on first
// contact. A race with a concurrent create only over- or under-reports created for // contact. A race with a concurrent create only over- or under-reports created for
// that one call, which the idempotent chat-access re-evaluation tolerates. // that one call, which the idempotent chat-access re-evaluation tolerates.
@@ -169,7 +211,31 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode,
if err != nil && !created { if err != nil && !created {
return Account{}, false, err return Account{}, false, err
} }
acc, err := s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName)) seed := telegramSeed(languageCode, username, firstName)
seed.timeZone = seedZone(browserTZ)
acc, err := s.provision(ctx, KindTelegram, externalID, seed)
return acc, created, err
}
// ProvisionVK provisions (or finds) the account bound to a VK identity, reporting
// whether this call created it (first contact). On first contact only, it seeds the new
// account's preferred language from the VK languageCode (vk_language, when it maps to a
// supported language) and its display name sanitized from displayName — the name read
// client-side via VKWebAppGetUserInfo, since VK omits it from the signed launch params —
// falling back to a generated placeholder when it yields no letters; an already-existing
// account is returned unchanged, so a later profile edit is never overwritten.
func (s *Store) ProvisionVK(ctx context.Context, externalID, languageCode, displayName, browserTZ string) (Account, bool, error) {
// Pre-check whether the identity already exists so the caller can act on first
// contact (mirrors ProvisionTelegram); a create race only mis-reports created for
// that one call.
_, err := s.findByIdentity(ctx, KindVK, externalID)
created := errors.Is(err, ErrNotFound)
if err != nil && !created {
return Account{}, false, err
}
seed := vkSeed(languageCode, displayName)
seed.timeZone = seedZone(browserTZ)
acc, err := s.provision(ctx, KindVK, externalID, seed)
return acc, created, err return acc, created, err
} }
@@ -197,20 +263,38 @@ func (s *Store) provision(ctx context.Context, kind, externalID string, seed pro
} }
// provisionSeed carries the optional create-time profile seed for a brand-new // provisionSeed carries the optional create-time profile seed for a brand-new
// account (Telegram first contact). Empty fields fall back to the accounts table // account (first contact). Empty fields fall back to the accounts table defaults,
// defaults, so an unknown language keeps the 'en' default and an empty name keeps // so an unknown language keeps the 'en' default, an empty name keeps the ” default
// the ” default. // and an empty time zone keeps the 'UTC' default.
type provisionSeed struct { type provisionSeed struct {
preferredLanguage string preferredLanguage string
displayName string displayName string
timeZone string
// isGuest creates the account flagged is_guest. It is set for an email-login
// account, which stays a guest until the address is confirmed (so an abandoned,
// never-confirmed login is reaped and its address freed); confirming clears the
// flag. Platform identities (telegram/vk) are durable from creation.
isGuest bool
}
// seedZone returns browserTZ when it is a well-formed zone to persist at account
// creation (a "±HH:MM" offset or a loadable IANA name), else "" so the new account
// falls back to the accounts table's 'UTC' default. The client reports the device's
// detected offset deterministically; a bad value is dropped rather than guessed at.
func seedZone(browserTZ string) string {
if validZone(browserTZ) {
return browserTZ
}
return ""
} }
// telegramSeed derives the create-time seed from Telegram launch fields: a // telegramSeed derives the create-time seed from Telegram launch fields: a
// supported preferred language from languageCode (an ISO-639 code, possibly // supported preferred language from languageCode (an ISO-639 code, possibly
// region-tagged like "ru-RU"), and a display name sanitized from firstName or, // region-tagged like "ru-RU"), and a display name. The name precedence is the real
// failing that, username (sanitizeDisplayName strips disallowed characters to the // name (firstName, sanitized to the editable format) → the @username taken verbatim
// editable format). When neither yields any letters, it falls back to a generated // (already a valid handle, only trimmed and length-capped, never character-stripped)
// placeholder in the seeded language (placeholderDisplayName). // → a generated placeholder in the seeded language (placeholderDisplayName), reached
// only when firstName has no usable letters and no username is set.
func telegramSeed(languageCode, username, firstName string) provisionSeed { func telegramSeed(languageCode, username, firstName string) provisionSeed {
var seed provisionSeed var seed provisionSeed
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" { if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
@@ -218,7 +302,13 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
} }
name := sanitizeDisplayName(firstName) name := sanitizeDisplayName(firstName)
if name == "" { if name == "" {
name = sanitizeDisplayName(username) // The real name yielded nothing usable: fall back to the @username verbatim
// (Telegram guarantees a valid handle), only trimmed and capped to the column
// width — never character-stripped like the real name.
name = strings.TrimSpace(username)
if r := []rune(name); len(r) > maxDisplayName {
name = strings.TrimRight(string(r[:maxDisplayName]), " ")
}
} }
if name == "" { if name == "" {
name = placeholderDisplayName(seed.preferredLanguage) name = placeholderDisplayName(seed.preferredLanguage)
@@ -227,6 +317,24 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
return seed return seed
} }
// vkSeed derives the create-time seed from VK launch fields: a supported preferred
// language from languageCode (vk_language, normally a 2-letter code) and a display name
// from displayName (sanitized to the editable format), falling back to a generated
// placeholder in the seeded language when the name yields no usable letters. Unlike
// telegramSeed there is no @username fallback — VK provides only the name.
func vkSeed(languageCode, displayName string) provisionSeed {
var seed provisionSeed
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
seed.preferredLanguage = lang
}
name := sanitizeDisplayName(displayName)
if name == "" {
name = placeholderDisplayName(seed.preferredLanguage)
}
seed.displayName = name
return seed
}
// GetByID loads the account identified by id, or ErrNotFound when it is absent. // GetByID loads the account identified by id, or ErrNotFound when it is absent.
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) { func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
stmt := postgres.SELECT(table.Accounts.AllColumns). stmt := postgres.SELECT(table.Accounts.AllColumns).
@@ -361,16 +469,22 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
var created Account var created Account
err = withTx(ctx, s.db, func(tx *sql.Tx) error { err = withTx(ctx, s.db, func(tx *sql.Tx) error {
// Seed the new row's display name and language (Telegram first contact); an // Seed the new row's display name, language and time zone (first contact); an
// empty seed reproduces the table defaults ('' and 'en') the other callers // empty seed reproduces the table defaults ('', 'en' and 'UTC') the other callers
// relied on, so their behaviour is unchanged. // relied on, so their behaviour is unchanged. time_zone is written explicitly (the
// detected offset, or 'UTC' equal to the column default) so a seeded zone lands at
// creation while an unseeded one stays UTC.
lang := seed.preferredLanguage lang := seed.preferredLanguage
if lang == "" { if lang == "" {
lang = "en" lang = "en"
} }
tz := seed.timeZone
if tz == "" {
tz = "UTC"
}
insertAccount := table.Accounts. insertAccount := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage). INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.IsGuest).
VALUES(accountID, seed.displayName, lang). VALUES(accountID, seed.displayName, lang, tz, seed.isGuest).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
var row model.Accounts var row model.Accounts
@@ -384,7 +498,7 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
table.Identities.Kind, table.Identities.Kind,
table.Identities.ExternalID, table.Identities.ExternalID,
table.Identities.Confirmed, table.Identities.Confirmed,
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram) ).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram || kind == KindVK)
if _, err := insertIdentity.ExecContext(ctx, tx); err != nil { if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
return err return err
} }
@@ -409,15 +523,21 @@ const guestDisplayName = "Guest"
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying // ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
// no identity, flagged is_guest, so it can hold a session and a game seat (both // no identity, flagged is_guest, so it can hold a session and a game seat (both
// foreign-key the accounts table) while being excluded from statistics, friends // foreign-key the accounts table) while being excluded from statistics, friends
// and history. Guests are not reused — each bootstrap mints a new account. // and history. Guests are not reused — each bootstrap mints a new account. browserTZ
func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) { // (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling
// back to the 'UTC' default when empty or malformed.
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) {
accountID, err := uuid.NewV7() accountID, err := uuid.NewV7()
if err != nil { if err != nil {
return Account{}, fmt.Errorf("account: new guest id: %w", err) return Account{}, fmt.Errorf("account: new guest id: %w", err)
} }
tz := seedZone(browserTZ)
if tz == "" {
tz = "UTC"
}
stmt := table.Accounts. stmt := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest). INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
VALUES(accountID, guestDisplayName, true). VALUES(accountID, guestDisplayName, true, tz).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
var row model.Accounts var row model.Accounts
+364 -35
View File
@@ -5,6 +5,7 @@ import (
crand "crypto/rand" crand "crypto/rand"
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"encoding/base64"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
@@ -26,6 +27,22 @@ const (
emailCodeTTL = 15 * time.Minute emailCodeTTL = 15 * time.Minute
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead. // emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
emailCodeMaxAttempts = 5 emailCodeMaxAttempts = 5
// linkTokenBytes is the entropy of a confirm deeplink token: 256 bits.
linkTokenBytes = 32
// emailConfirmPath is the SPA route the one-tap confirm deeplink opens (the token
// is appended). The SPA is served under /app/ behind a hash router.
emailConfirmPath = "/app/#/confirm/"
)
// Confirmation purposes recorded on a pending confirm-code row. They select what
// verifying the code or the deeplink token does: sign in (login), link/confirm the
// address on the current account (link), or replace the account's confirmed email with
// a new address (change). Account deletion adds a further purpose in a later stage.
const (
purposeLogin = "login"
purposeLink = "link"
purposeChange = "change"
purposeDelete = "delete"
) )
// Errors returned by the email confirm-code flow. // Errors returned by the email confirm-code flow.
@@ -46,6 +63,12 @@ var (
ErrTooManyAttempts = errors.New("account: too many confirmation attempts") ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
// ErrCodeMismatch is returned when the submitted code does not match. // ErrCodeMismatch is returned when the submitted code does not match.
ErrCodeMismatch = errors.New("account: confirmation code does not match") ErrCodeMismatch = errors.New("account: confirmation code does not match")
// ErrTooManyRequests is returned when confirm-code sends to an address are being
// requested too frequently (the resend cooldown or the rolling-hour cap).
ErrTooManyRequests = errors.New("account: too many code requests")
// ErrNoEmail is returned when an email-code step-up is requested for an account that
// holds no confirmed email (the caller must use the typed-phrase path instead).
ErrNoEmail = errors.New("account: no confirmed email")
) )
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a // EmailService runs the email confirm-code flow: it issues a 6-digit code over a
@@ -55,14 +78,82 @@ var (
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow — // account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
// and using an email as a login reuses this mechanism. // and using an email as a login reuses this mechanism.
type EmailService struct { type EmailService struct {
store *Store store *Store
mailer Mailer mailer Mailer
now func() time.Time baseURL string
limiter *SendLimiter
now func() time.Time
} }
// NewEmailService constructs an EmailService over store, sending via mailer. // NewEmailService constructs an EmailService over store, sending via mailer. baseURL
func NewEmailService(store *Store, mailer Mailer) *EmailService { // is the canonical public origin (scheme + host) used to build the one-tap confirm
return &EmailService{store: store, mailer: mailer, now: func() time.Time { return time.Now().UTC() }} // deeplink and the email footer landing link; an empty baseURL omits the deeplink
// (development / log mailer).
func NewEmailService(store *Store, mailer Mailer, baseURL string) *EmailService {
return &EmailService{store: store, mailer: mailer, baseURL: baseURL, now: func() time.Time { return time.Now().UTC() }}
}
// SetSendLimiter installs a per-recipient send throttle. When unset (nil), sends are
// not throttled — production wires a limiter; tests leave it off.
func (s *EmailService) SetSendLimiter(l *SendLimiter) { s.limiter = l }
// allowSend reports whether a confirm-code send to email is permitted now, recording
// it when so. A nil limiter permits every send.
func (s *EmailService) allowSend(email string) bool {
return s.limiter == nil || s.limiter.Allow(email)
}
// issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID,
// email), replaces any prior pending confirmation, and mails the branded code in
// locale; purpose selects the email wording and what verifying does. omitLink drops the
// one-tap deeplink from the email (account deletion, or a login requested from an installed
// PWA). Only the SHA-256 hashes of the code and token are stored.
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string, omitLink bool) error {
code, codeHash, err := generateCode()
if err != nil {
return err
}
token, tokenHash, err := generateLinkToken()
if err != nil {
return err
}
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
return err
}
// Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would
// open in a separate browser whose minted session cannot reach the PWA), or for account
// deletion (a prefetch or stray click must not delete an account — the delete code is entered
// in the app only, and ConfirmByToken refuses a delete token).
deeplink := s.confirmURL(token, locale)
if purpose == purposeDelete || omitLink {
deeplink = ""
}
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
if err != nil {
return err
}
msg.To = email
return s.mailer.Send(ctx, msg)
}
// confirmURL builds the absolute one-tap confirm deeplink for token in locale, or ""
// when no public base URL is configured. The locale rides the fragment as ?lang so the
// confirm screen (opened in a browser with no session) renders in the email's language.
func (s *EmailService) confirmURL(token, locale string) string {
if s.baseURL == "" {
return ""
}
return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale)
}
// accountLocale returns the account's preferred UI language for localising email,
// defaulting to "en" when the account cannot be loaded.
func (s *EmailService) accountLocale(ctx context.Context, accountID uuid.UUID) string {
acc, err := s.store.GetByID(ctx, accountID)
if err != nil {
return "en"
}
return acc.PreferredLanguage
} }
// RequestCode issues a fresh confirm-code for email to accountID and mails it, // RequestCode issues a fresh confirm-code for email to accountID and mails it,
@@ -73,6 +164,9 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
if err != nil { if err != nil {
return err return err
} }
if !s.allowSend(addr) {
return ErrTooManyRequests
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr) owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil { if err != nil {
return err return err
@@ -83,16 +177,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
} }
return ErrEmailTaken return ErrEmailTaken
} }
code, hash, err := generateCode() return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
if err != nil {
return err
}
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
return err
}
subject := "Your Scrabble confirmation code"
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
return s.mailer.Send(ctx, addr, subject, body)
} }
// ConfirmCode verifies code for accountID and email. On success it attaches a // ConfirmCode verifies code for accountID and email. On success it attaches a
@@ -123,34 +208,38 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil { if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return Account{}, err return Account{}, err
} }
// Binding the first confirmed email promotes a guest to a durable account, matching the
// link and deeplink flows (defence-in-depth: no confirmed-email path leaves is_guest set).
if err := s.store.ClearGuest(ctx, accountID); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID) return s.store.GetByID(ctx, accountID)
} }
// RequestLoginCode issues a login confirm-code to the account that owns email, // RequestLoginCode issues a login confirm-code to the account that owns email,
// provisioning a fresh (unconfirmed) durable account when the email is new. It is // provisioning a fresh (unconfirmed) guest account when the email is new — it becomes
// the unauthenticated email-login entry point and, unlike RequestCode, // durable once the code is confirmed. It is the unauthenticated email-login entry
// does not refuse an already-confirmed email — that is the ordinary returning-user // point and, unlike RequestCode, does not refuse an already-confirmed email — that is
// login. The code is mailed to the address, so only its real owner can complete // the ordinary returning-user login. The code is mailed to the address, so only its
// the login. It returns the target account id for the subsequent LoginWithCode. // real owner can complete the login. On first contact browserTZ (the client's
func (s *EmailService) RequestLoginCode(ctx context.Context, email string) (uuid.UUID, error) { // detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
// language. When pwa is set (the request came from an installed PWA) the login email omits the
// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the
// code is entered in the same window. It returns the target account id for the subsequent
// LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string, pwa bool) (uuid.UUID, error) {
addr, err := normalizeEmail(email) addr, err := normalizeEmail(email)
if err != nil { if err != nil {
return uuid.UUID{}, err return uuid.UUID{}, err
} }
acc, err := s.store.ProvisionByIdentity(ctx, KindEmail, addr) if !s.allowSend(addr) {
return uuid.UUID{}, ErrTooManyRequests
}
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ, language)
if err != nil { if err != nil {
return uuid.UUID{}, err return uuid.UUID{}, err
} }
code, hash, err := generateCode() if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language, pwa); err != nil {
if err != nil {
return uuid.UUID{}, err
}
if err := s.store.replacePendingConfirmation(ctx, acc.ID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
return uuid.UUID{}, err
}
subject := "Your Scrabble login code"
body := fmt.Sprintf("Your login code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
if err := s.mailer.Send(ctx, addr, subject, body); err != nil {
return uuid.UUID{}, err return uuid.UUID{}, err
} }
return acc.ID, nil return acc.ID, nil
@@ -189,9 +278,104 @@ func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (A
if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil { if err := s.store.confirmEmailLogin(ctx, conf.id, acc.ID, addr, s.now()); err != nil {
return Account{}, err return Account{}, err
} }
if err := s.store.ClearGuest(ctx, acc.ID); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, acc.ID) return s.store.GetByID(ctx, acc.ID)
} }
// LinkConfirmation is the outcome of confirming a one-tap deeplink token: what the
// transport layer must finish. Purpose is the pending row's purpose. For a login,
// Account is the account to sign in. For a link, Account is the account the email was
// (or would be) attached to; NeedsMerge is set when another account (MergeOwner)
// already owns the address, so the caller drives the interactive merge instead of a
// plain link — the token is left unconsumed for that merge step.
type LinkConfirmation struct {
Purpose string
Account uuid.UUID
NeedsMerge bool
MergeOwner uuid.UUID
}
// IsLogin reports whether the confirmation is a login (the caller mints a session)
// rather than a link (attach the identity, or drive a merge).
func (r LinkConfirmation) IsLogin() bool { return r.Purpose == purposeLogin }
// ConfirmByToken verifies a one-tap deeplink token and performs its purpose. A login
// confirms the email identity, clears the guest flag and returns the account to sign
// in. A link attaches the confirmed email to the pending account when the address is
// free, or reports NeedsMerge when another account already owns it (leaving the token
// live so the caller's merge step can re-verify). It returns ErrNoPendingCode when the
// token matches no live confirmation and ErrCodeExpired when it has lapsed. The token
// is high-entropy, so there is no wrong-attempt counter.
func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkConfirmation, error) {
pend, err := s.store.pendingByTokenHash(ctx, hashCode(token))
if err != nil {
return LinkConfirmation{}, err
}
if s.now().After(pend.expiresAt) {
return LinkConfirmation{}, ErrCodeExpired
}
switch pend.purpose {
case purposeLogin:
if err := s.store.confirmEmailLogin(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
return LinkConfirmation{}, err
}
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeLogin, Account: pend.accountID}, nil
case purposeLink:
owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
if err != nil {
return LinkConfirmation{}, err
}
if ok {
if owner == pend.accountID {
if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
}
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID, NeedsMerge: true, MergeOwner: owner}, nil
}
if err := s.store.confirmEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
return LinkConfirmation{}, err
}
// Binding the first email promotes a guest to a durable account, matching the
// code-based link flow (which clears the guest flag in the link service).
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
case purposeChange:
owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
if err != nil {
return LinkConfirmation{}, err
}
if ok && owner != pend.accountID {
// The new address is confirmed by a different account: refuse without
// disclosing it (anti-enumeration). Unlike a link, a change never merges.
return LinkConfirmation{}, ErrEmailTaken
}
if ok && owner == pend.accountID {
if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
}
if err := s.store.replaceEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
case purposeDelete:
// Deletion is confirmed in the app with the code, never via a one-tap link.
return LinkConfirmation{}, fmt.Errorf("account: deletion cannot be confirmed by link")
default:
return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
}
}
// emailConfirmation is a pending confirm-code row in domain form. // emailConfirmation is a pending confirm-code row in domain form.
type emailConfirmation struct { type emailConfirmation struct {
id uuid.UUID id uuid.UUID
@@ -220,9 +404,30 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U
return row.AccountID, true, nil return row.AccountID, true, nil
} }
// confirmedEmailOf returns the account's confirmed email address and true, or ("", false)
// when it holds none. It backs the deletion step-up, which mails a code to the account's
// own address.
func (s *Store) confirmedEmailOf(ctx context.Context, accountID uuid.UUID) (string, bool, error) {
stmt := postgres.SELECT(table.Identities.ExternalID).
FROM(table.Identities).
WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))).
AND(table.Identities.Confirmed.EQ(postgres.Bool(true))),
).LIMIT(1)
var row model.Identities
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return "", false, nil
}
return "", false, fmt.Errorf("account: confirmed email of %s: %w", accountID, err)
}
return row.ExternalID, true, nil
}
// replacePendingConfirmation clears any pending code for (accountID, email) and // replacePendingConfirmation clears any pending code for (accountID, email) and
// inserts a fresh one, inside one transaction. // inserts a fresh one, inside one transaction.
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash string, expiresAt time.Time) error { func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error {
id, err := uuid.NewV7() id, err := uuid.NewV7()
if err != nil { if err != nil {
return fmt.Errorf("account: new confirmation id: %w", err) return fmt.Errorf("account: new confirmation id: %w", err)
@@ -239,7 +444,8 @@ func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.U
ins := table.EmailConfirmations.INSERT( ins := table.EmailConfirmations.INSERT(
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID, table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt, table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
).VALUES(id, accountID, email, codeHash, expiresAt) table.EmailConfirmations.LinkTokenHash, table.EmailConfirmations.Purpose,
).VALUES(id, accountID, email, codeHash, expiresAt, linkTokenHash, purpose)
if _, err := ins.ExecContext(ctx, tx); err != nil { if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert confirmation: %w", err) return fmt.Errorf("insert confirmation: %w", err)
} }
@@ -272,6 +478,54 @@ func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UU
}, nil }, nil
} }
// pendingConfirmation is a pending confirm-code row loaded by its deeplink token, in
// domain form.
type pendingConfirmation struct {
id uuid.UUID
accountID uuid.UUID
email string
purpose string
expiresAt time.Time
}
// pendingByTokenHash loads the unconsumed confirmation whose deeplink token hashes to
// tokenHash, or ErrNoPendingCode. The high-entropy token needs no attempt counter, so
// a partial-unique index guarantees at most one match.
func (s *Store) pendingByTokenHash(ctx context.Context, tokenHash string) (pendingConfirmation, error) {
stmt := postgres.SELECT(table.EmailConfirmations.AllColumns).
FROM(table.EmailConfirmations).
WHERE(
table.EmailConfirmations.LinkTokenHash.EQ(postgres.String(tokenHash)).
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
).LIMIT(1)
var row model.EmailConfirmations
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return pendingConfirmation{}, ErrNoPendingCode
}
return pendingConfirmation{}, fmt.Errorf("account: load confirmation by token: %w", err)
}
return pendingConfirmation{
id: row.ConfirmationID,
accountID: row.AccountID,
email: row.Email,
purpose: row.Purpose,
expiresAt: row.ExpiresAt,
}, nil
}
// consumeConfirmation marks a confirmation consumed without writing an identity, used
// for the idempotent already-linked deeplink path.
func (s *Store) consumeConfirmation(ctx context.Context, id uuid.UUID, now time.Time) error {
upd := table.EmailConfirmations.UPDATE(table.EmailConfirmations.ConsumedAt).
SET(postgres.TimestampzT(now)).
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id)))
if _, err := upd.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: consume confirmation: %w", err)
}
return nil
}
// bumpConfirmationAttempts increments a code's wrong-attempt counter by one. // bumpConfirmationAttempts increments a code's wrong-attempt counter by one.
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error { func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
stmt := table.EmailConfirmations. stmt := table.EmailConfirmations.
@@ -318,6 +572,69 @@ func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accoun
return nil return nil
} }
// replaceEmailIdentity consumes the confirmation, deletes the account's existing email
// identity (freeing the old address) and inserts newEmail as its confirmed email, inside
// one transaction. It backs the change-email flow. A unique-constraint violation — the
// new address was confirmed elsewhere in the meantime — surfaces as ErrEmailTaken. When
// the account holds no email identity yet the delete is a no-op, so this doubles as an
// attach.
func (s *Store) replaceEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, newEmail string, now time.Time) error {
identityID, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new identity id: %w", err)
}
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
upd := table.EmailConfirmations.
UPDATE(table.EmailConfirmations.ConsumedAt).
SET(postgres.TimestampzT(now)).
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("consume confirmation: %w", err)
}
// Journal the outgoing email before replacing it, so the legal dossier keeps the
// address the account used to hold (see retention.go).
var old model.Identities
sel := postgres.SELECT(
table.Identities.ExternalID, table.Identities.Confirmed, table.Identities.CreatedAt,
).FROM(table.Identities).WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
).LIMIT(1)
switch err := sel.QueryContext(ctx, tx, &old); {
case err == nil:
if err := retainIdentityTx(ctx, tx, accountID, KindEmail, old.ExternalID, old.Confirmed, old.CreatedAt, retainChange); err != nil {
return err
}
case errors.Is(err, qrm.ErrNoRows):
// No prior email (this doubles as an attach); nothing to retain.
default:
return fmt.Errorf("load outgoing email identity: %w", err)
}
del := table.Identities.DELETE().WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
)
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("delete old email identity: %w", err)
}
ins := table.Identities.INSERT(
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
table.Identities.ExternalID, table.Identities.Confirmed,
).VALUES(identityID, accountID, KindEmail, newEmail, true)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return err
}
return nil
})
if err != nil {
if isUniqueViolation(err) {
return ErrEmailTaken
}
return fmt.Errorf("account: replace email identity: %w", err)
}
return nil
}
// confirmEmailLogin consumes the login code and marks the existing email // confirmEmailLogin consumes the login code and marks the existing email
// identity confirmed, inside one transaction. The identity already exists (a // identity confirmed, inside one transaction. The identity already exists (a
// login provisioned it), so this updates rather than inserts and is idempotent // login provisioned it), so this updates rather than inserts and is idempotent
@@ -365,6 +682,18 @@ func generateCode() (code, hash string, err error) {
return code, hashCode(code), nil return code, hashCode(code), nil
} }
// generateLinkToken returns a fresh opaque one-tap confirm deeplink token (URL-safe
// base64, 256-bit) and its hex SHA-256 hash. Only the hash is stored; the token
// travels only in the emailed link, mirroring the session-token model.
func generateLinkToken() (token, hash string, err error) {
buf := make([]byte, linkTokenBytes)
if _, err := crand.Read(buf); err != nil {
return "", "", fmt.Errorf("account: generate link token: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(buf)
return token, hashCode(token), nil
}
// hashCode returns the hex-encoded SHA-256 of a confirm-code. // hashCode returns the hex-encoded SHA-256 of a confirm-code.
func hashCode(code string) string { func hashCode(code string) string {
sum := sha256.Sum256([]byte(code)) sum := sha256.Sum256([]byte(code))
+239
View File
@@ -0,0 +1,239 @@
package account
import (
"fmt"
"html/template"
"strings"
tmpltext "text/template"
"time"
)
// emailBrandColor is the single accent used in the confirmation email — a calm
// tile green, matching the "no riot of colours" brief.
const emailBrandColor = "#2f7d4f"
// confirmEmailView is the fully-localised data the confirmation email templates
// render. Every string is resolved before rendering, so the templates carry no
// localisation logic.
type confirmEmailView struct {
Brand string
Heading string
Intro string
Code string
Expiry string
CTALabel string
DeeplinkURL string
FooterIgnore string
LandingURL string
LandingLabel string
Preheader string
Locale string
Accent string
}
// emailCopy is the purpose- and locale-specific wording of a confirmation email.
type emailCopy struct {
Subject string
Preheader string
Heading string
Intro string
CTALabel string
FooterIgnore string
}
// confirmEmailCopy holds the wording per (purpose, locale). Unknown purposes fall
// back to the neutral link wording and unknown locales fall back to English.
var confirmEmailCopy = map[string]map[string]emailCopy{
purposeLogin: {
"en": {
Subject: "Your Erudit sign-in code",
Preheader: "Your sign-in code",
Heading: "Sign in to Erudit",
Intro: "Enter this code to sign in:",
CTALabel: "Sign in with one tap",
FooterIgnore: "If you didn't request this email, you can safely ignore it.",
},
"ru": {
Subject: "Код для входа в Эрудит",
Preheader: "Ваш код для входа",
Heading: "Вход в Эрудит",
Intro: "Введите этот код, чтобы войти в игру:",
CTALabel: "Войти одним нажатием",
FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
},
},
purposeLink: {
"en": {
Subject: "Your Erudit confirmation code",
Preheader: "Your confirmation code",
Heading: "Confirm your e-mail",
Intro: "Enter this code to confirm your address:",
CTALabel: "Confirm with one tap",
FooterIgnore: "If you didn't request this email, you can safely ignore it.",
},
"ru": {
Subject: "Код подтверждения Эрудит",
Preheader: "Ваш код подтверждения",
Heading: "Подтверждение e-mail",
Intro: "Введите этот код, чтобы подтвердить адрес:",
CTALabel: "Подтвердить одним нажатием",
FooterIgnore: "Если вы не запрашивали это письмо, просто проигнорируйте его.",
},
},
purposeChange: {
"en": {
Subject: "Confirm your new Erudit e-mail",
Preheader: "Confirm your new address",
Heading: "Confirm your new e-mail",
Intro: "Enter this code to switch your account to this address:",
CTALabel: "Confirm with one tap",
FooterIgnore: "If you didn't request this change, you can safely ignore it — your address stays the same.",
},
"ru": {
Subject: "Подтвердите новый e-mail в Эрудит",
Preheader: "Подтвердите новый адрес",
Heading: "Смена e-mail",
Intro: "Введите этот код, чтобы привязать аккаунт к новому адресу:",
CTALabel: "Подтвердить одним нажатием",
FooterIgnore: "Если вы не запрашивали смену, просто проигнорируйте письмо — адрес останется прежним.",
},
},
purposeDelete: {
"en": {
Subject: "Confirm your Erudit account deletion",
Preheader: "Confirm account deletion",
Heading: "Delete your account",
Intro: "Enter this code in the app to permanently delete your account:",
CTALabel: "",
FooterIgnore: "If you didn't request this, ignore it — your account stays as it is.",
},
"ru": {
Subject: "Подтвердите удаление аккаунта Эрудит",
Preheader: "Подтверждение удаления аккаунта",
Heading: "Удаление аккаунта",
Intro: "Введите этот код в приложении, чтобы удалить аккаунт без восстановления:",
CTALabel: "",
FooterIgnore: "Если вы не запрашивали удаление, проигнорируйте письмо — аккаунт останется.",
},
},
}
// emailBrand is the brand wordmark per locale.
var emailBrand = map[string]string{"en": "Erudit", "ru": "Эрудит"}
// emailExpiry formats the code-lifetime line per locale (abbreviated minutes to
// avoid plural agreement).
func emailExpiry(locale string, d time.Duration) string {
min := int(d / time.Minute)
if locale == "ru" {
return fmt.Sprintf("Код действует %d мин.", min)
}
return fmt.Sprintf("The code is valid for %d minutes.", min)
}
// normalizeLocale maps an account language to a supported email locale, defaulting
// to English.
func normalizeLocale(locale string) string {
if locale == "ru" {
return "ru"
}
return "en"
}
// renderConfirmationEmail builds the branded confirmation email for purpose in
// locale: a large readable code plus a one-tap deeplink button, with an
// ignore-notice footer and a landing link. Both a plain-text body and an HTML
// alternative are produced. deeplinkURL is the absolute /confirm link and
// landingURL the public landing origin.
func renderConfirmationEmail(purpose, code, deeplinkURL, landingURL, locale string) (Message, error) {
loc := normalizeLocale(locale)
byLocale, ok := confirmEmailCopy[purpose]
if !ok {
byLocale = confirmEmailCopy[purposeLink]
}
cp := byLocale[loc]
view := confirmEmailView{
Brand: emailBrand[loc],
Heading: cp.Heading,
Intro: cp.Intro,
Code: code,
Expiry: emailExpiry(loc, emailCodeTTL),
CTALabel: cp.CTALabel,
DeeplinkURL: deeplinkURL,
FooterIgnore: cp.FooterIgnore,
LandingURL: landingURL,
LandingLabel: emailBrand[loc],
Preheader: cp.Preheader,
Locale: loc,
Accent: emailBrandColor,
}
var html strings.Builder
if err := confirmEmailHTML.Execute(&html, view); err != nil {
return Message{}, fmt.Errorf("account: render confirmation email (html): %w", err)
}
var text strings.Builder
if err := confirmEmailText.Execute(&text, view); err != nil {
return Message{}, fmt.Errorf("account: render confirmation email (text): %w", err)
}
return Message{Subject: cp.Subject, Text: text.String(), HTML: html.String()}, nil
}
// confirmEmailHTML is a compact, image-free, mobile-friendly HTML email. Layout is
// table-based for broad mail-client compatibility and all styling is inlined
// because clients strip <style> blocks.
var confirmEmailHTML = template.Must(template.New("confirmEmailHTML").Parse(`<!DOCTYPE html>
<html lang="{{.Locale}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Brand}}</title>
</head>
<body style="margin:0;padding:0;background:#f4f5f7;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;">{{.Preheader}}</span>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f5f7;padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:460px;background:#ffffff;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<tr><td style="padding:28px 32px 4px;">
<div style="font-size:15px;font-weight:700;letter-spacing:.04em;color:{{.Accent}};">{{.Brand}}</div>
</td></tr>
<tr><td style="padding:8px 32px 0;">
<h1 style="margin:0;font-size:20px;line-height:1.3;color:#111827;font-weight:600;">{{.Heading}}</h1>
<p style="margin:12px 0 0;font-size:15px;line-height:1.5;color:#374151;">{{.Intro}}</p>
</td></tr>
<tr><td style="padding:18px 32px 0;">
<div style="font-size:34px;font-weight:700;letter-spacing:8px;text-align:center;color:#111827;background:#f3f4f6;border-radius:10px;padding:18px 0;font-family:'SFMono-Regular',Consolas,Menlo,monospace;">{{.Code}}</div>
<p style="margin:10px 0 0;font-size:13px;line-height:1.5;color:#6b7280;text-align:center;">{{.Expiry}}</p>
</td></tr>
{{if .DeeplinkURL}}<tr><td style="padding:22px 32px 0;" align="center">
<a href="{{.DeeplinkURL}}" style="display:inline-block;background:{{.Accent}};color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;padding:12px 26px;border-radius:9px;">{{.CTALabel}}</a>
</td></tr>
{{end}}<tr><td style="padding:26px 32px 28px;">
<hr style="border:none;border-top:1px solid #eceef1;margin:0 0 16px;">
<p style="margin:0;font-size:12px;line-height:1.6;color:#9ca3af;">{{.FooterIgnore}}</p>
<p style="margin:10px 0 0;font-size:12px;color:#9ca3af;"><a href="{{.LandingURL}}" style="color:#6b7280;text-decoration:none;">{{.LandingLabel}}</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
`))
// confirmEmailText is the plain-text alternative (and multipart fallback).
var confirmEmailText = tmpltext.Must(tmpltext.New("confirmEmailText").Parse(`{{.Brand}}
{{.Heading}}
{{.Intro}}
{{.Code}}
{{.Expiry}}
{{if .DeeplinkURL}}{{.CTALabel}}:
{{.DeeplinkURL}}
{{end}}
{{.FooterIgnore}}
{{.LandingLabel}}{{.LandingURL}}
`))
@@ -0,0 +1,54 @@
package account
import (
"strings"
"testing"
)
// TestRenderConfirmationEmail checks that each (purpose, locale) renders a localised
// subject, embeds the code and the one-tap deeplink in both bodies, and produces HTML.
func TestRenderConfirmationEmail(t *testing.T) {
const deeplink = "https://erudit-game.ru/app/#/confirm/tok123"
cases := []struct {
name, purpose, locale, subjectSub string
}{
{"login ru", purposeLogin, "ru", "вход"},
{"login en", purposeLogin, "en", "sign-in"},
{"link ru", purposeLink, "ru", "подтвержд"},
{"link en", purposeLink, "en", "confirmation"},
{"change ru", purposeChange, "ru", "новый"},
{"change en", purposeChange, "en", "new"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
msg, err := renderConfirmationEmail(c.purpose, "123456", deeplink, "https://erudit-game.ru", c.locale)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(strings.ToLower(msg.Subject), c.subjectSub) {
t.Errorf("subject %q does not contain %q", msg.Subject, c.subjectSub)
}
if !strings.Contains(msg.Text, "123456") || !strings.Contains(msg.HTML, "123456") {
t.Error("code missing from a body")
}
if !strings.Contains(msg.Text, deeplink) || !strings.Contains(msg.HTML, "confirm/tok123") {
t.Error("deeplink missing from a body")
}
if !strings.Contains(msg.HTML, "<html") {
t.Error("HTML body is not HTML")
}
})
}
}
// TestRenderConfirmationEmailUnknownLocaleDefaultsEnglish falls back to English for an
// unsupported locale rather than erroring or emitting an empty subject.
func TestRenderConfirmationEmailUnknownLocaleDefaultsEnglish(t *testing.T) {
msg, err := renderConfirmationEmail(purposeLogin, "000000", "", "https://erudit-game.ru", "de")
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(strings.ToLower(msg.Subject), "sign-in") {
t.Errorf("unknown locale should default to English, got subject %q", msg.Subject)
}
}
+160 -9
View File
@@ -2,6 +2,7 @@ package account
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"time" "time"
@@ -16,6 +17,68 @@ import (
// belongs to another account; the caller turns it into a merge. // belongs to another account; the caller turns it into a merge.
var ErrIdentityTaken = errors.New("account: identity already linked to another account") var ErrIdentityTaken = errors.New("account: identity already linked to another account")
// ErrLastIdentity is returned when removing an identity would leave the account with
// none, making it unreachable after logout. The admin email-erase refuses it.
var ErrLastIdentity = errors.New("account: cannot remove the last identity")
// RemoveIdentity deletes the account's identity of the given kind (and, for an email,
// any pending confirmations for it), freeing it for reuse. It refuses when that is the
// account's only identity (ErrLastIdentity) — which would leave the account
// unreachable — and returns ErrNotFound when the account has no identity of that kind.
// It backs the profile Unlink control and the admin "erase email" action.
func (s *Store) RemoveIdentity(ctx context.Context, accountID uuid.UUID, kind string) error {
ids, err := s.Identities(ctx, accountID)
if err != nil {
return err
}
var toRetain []Identity
others := 0
for _, id := range ids {
if id.Kind == kind {
toRetain = append(toRetain, id)
} else {
others++
}
}
if len(toRetain) == 0 {
return ErrNotFound
}
if others == 0 {
return ErrLastIdentity
}
return withTx(ctx, s.db, func(tx *sql.Tx) error {
// Journal the detached credential before removing it, so the legal dossier
// survives while the identity frees for reuse (see retention.go).
for _, id := range toRetain {
if err := retainIdentityTx(ctx, tx, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainUnlink); err != nil {
return err
}
}
delID := table.Identities.DELETE().WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(kind))),
)
if _, err := delID.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("account: delete %s identity %s: %w", kind, accountID, err)
}
if kind == KindEmail {
delConf := table.EmailConfirmations.DELETE().WHERE(
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)),
)
if _, err := delConf.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("account: delete email confirmations %s: %w", accountID, err)
}
}
return nil
})
}
// RemoveEmailIdentity erases the account's email identity. It backs the admin console's
// "erase email" action; the user-facing profile never unlinks email (it is changed).
func (s *Store) RemoveEmailIdentity(ctx context.Context, accountID uuid.UUID) error {
return s.RemoveIdentity(ctx, accountID, KindEmail)
}
// RequestLinkCode issues and mails a confirm-code for email to accountID, // RequestLinkCode issues and mails a confirm-code for email to accountID,
// replacing any prior pending code. Unlike RequestCode it never refuses up front // replacing any prior pending code. Unlike RequestCode it never refuses up front
// (taken or already-confirmed): possession of the address is the authorization for // (taken or already-confirmed): possession of the address is the authorization for
@@ -26,16 +89,10 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
if err != nil { if err != nil {
return err return err
} }
code, hash, err := generateCode() if !s.allowSend(addr) {
if err != nil { return ErrTooManyRequests
return err
} }
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil { return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
return err
}
subject := "Your Scrabble confirmation code"
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
return s.mailer.Send(ctx, addr, subject, body)
} }
// ConfirmLink verifies code for (accountID, email) and reports the address's // ConfirmLink verifies code for (accountID, email) and reports the address's
@@ -70,6 +127,100 @@ func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, ema
return accountID, true, nil return accountID, true, nil
} }
// RequestChangeCode issues and mails a confirm-code to newEmail for an authenticated
// email change on accountID, replacing any prior pending code. Like RequestLinkCode it
// never refuses up front on "taken" (anti-enumeration): possession of newEmail is the
// authorization, and a conflict with another account is revealed only at confirm — as a
// non-disclosing refusal, never a merge.
func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUID, newEmail string) error {
addr, err := normalizeEmail(newEmail)
if err != nil {
return err
}
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID), false)
}
// ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the
// account's confirmed email with newEmail, freeing the old address. When newEmail is
// already confirmed by another account it refuses with ErrEmailTaken (surfaced to the
// user as a non-disclosing "check the address or contact support"), never merging; when
// the account already owns newEmail it is an idempotent no-op. It returns the usual
// confirm-code errors (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts,
// ErrCodeMismatch) and the updated account on success.
func (s *EmailService) ConfirmChange(ctx context.Context, accountID uuid.UUID, newEmail, code string) (Account, error) {
addr, err := normalizeEmail(newEmail)
if err != nil {
return Account{}, err
}
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
if err != nil {
return Account{}, err
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil {
return Account{}, err
}
if ok && owner != accountID {
return Account{}, ErrEmailTaken
}
if ok && owner == accountID {
if err := s.store.consumeConfirmation(ctx, conf.id, s.now()); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID)
}
if err := s.store.replaceEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID)
}
// HasEmail reports whether accountID owns a confirmed email. The account-deletion step-up
// mails a confirm-code when it does, and falls back to a typed phrase otherwise.
func (s *EmailService) HasEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
_, ok, err := s.store.confirmedEmailOf(ctx, accountID)
return ok, err
}
// RequestDeleteCode mails an account-deletion confirm-code to the account's own confirmed
// email (no deeplink — deletion is confirmed in the app). It returns ErrNoEmail when the
// account holds no email, ErrTooManyRequests when throttled.
func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUID) error {
addr, ok, err := s.store.confirmedEmailOf(ctx, accountID)
if err != nil {
return err
}
if !ok {
return ErrNoEmail
}
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID), false)
}
// VerifyDeleteCode verifies the account-deletion code against the account's own email and
// consumes it on success. It returns ErrNoEmail (no email), the usual confirm-code errors
// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch), or nil when the
// code is valid — the caller then performs the deletion.
func (s *EmailService) VerifyDeleteCode(ctx context.Context, accountID uuid.UUID, code string) error {
addr, ok, err := s.store.confirmedEmailOf(ctx, accountID)
if err != nil {
return err
}
if !ok {
return ErrNoEmail
}
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
if err != nil {
return err
}
return s.store.consumeConfirmation(ctx, conf.id, s.now())
}
// verifyPendingCode loads and checks the pending confirm-code for (accountID, // verifyPendingCode loads and checks the pending confirm-code for (accountID,
// addr), counting a wrong attempt. It returns the confirmation on success. // addr), counting a wrong attempt. It returns the confirmation on success.
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) { func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
+122 -29
View File
@@ -3,33 +3,99 @@ package account
import ( import (
"context" "context"
"fmt" "fmt"
"net" "strconv"
"net/smtp" "strings"
"time"
"github.com/wneessen/go-mail"
"go.uber.org/zap" "go.uber.org/zap"
) )
// Message is a transactional email to send through a Mailer. Text is the
// required plain-text body and doubles as the multipart/alternative fallback;
// HTML, when non-empty, is the preferred body a capable client renders instead.
type Message struct {
// To is the recipient address, or several comma-separated (all get the one message).
To string
// From, when non-empty, overrides the configured sender for this message — the admin
// alert path uses a distinct From from the user-facing confirm-code sender.
From string
Subject string
Text string
HTML string
}
// Mailer delivers a transactional email. It is the seam behind which the email // Mailer delivers a transactional email. It is the seam behind which the email
// confirm-code flow sends codes, so the relay is swappable and unit tests use a // confirm-code flow sends codes, so the relay is swappable and unit tests use a
// fixture (see docs/TESTING.md: no real network in tests). The context is offered // fixture (see docs/TESTING.md: no real network in tests). The context bounds the
// for cancellation; the standard-library SMTP implementation sends synchronously // delivery and is honoured by the SMTP implementation.
// and ignores it.
type Mailer interface { type Mailer interface {
Send(ctx context.Context, to, subject, body string) error Send(ctx context.Context, msg Message) error
}
// splitAddrs splits a comma-separated recipient list into trimmed, non-empty addresses.
func splitAddrs(list string) []string {
parts := strings.Split(list, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if a := strings.TrimSpace(p); a != "" {
out = append(out, a)
}
}
return out
} }
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer // SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer
// instead, so a deployment without a relay still runs (the code lands in the log). // instead, so a deployment without a relay still runs (the code lands in the log).
// TLS is always used and no client certificate is required — only the server
// certificate is validated against the system roots.
type SMTPConfig struct { type SMTPConfig struct {
Host string Host string
Port string Port string
Username string Username string
Password string Password string
From string From string
// TLS selects the transport security: "ssl" for implicit TLS from connect, or
// "starttls" to upgrade a plaintext connection. Empty derives the mode from the
// 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
} }
// SMTPMailer sends mail through an SMTP relay using the standard library. When a const (
// username is set it authenticates with PLAIN; otherwise it relays unauthenticated. // SMTP transport-security modes for SMTPConfig.TLS.
smtpTLSImplicit = "ssl"
smtpTLSSTARTTLS = "starttls"
// smtpDialTimeout bounds a single relay connect-and-send. The confirm-code send
// is synchronous on the request path, so an unreachable relay must fail fast
// rather than hold the request open.
smtpDialTimeout = 15 * time.Second
)
// tlsMode resolves the transport-security mode for the relay: the explicitly
// configured SMTPConfig.TLS, or — when unset — implicit TLS on the conventional SSL
// port 465 and STARTTLS on any other port.
func (cfg SMTPConfig) tlsMode(port int) string {
switch strings.ToLower(strings.TrimSpace(cfg.TLS)) {
case smtpTLSImplicit, "tls":
return smtpTLSImplicit
case smtpTLSSTARTTLS:
return smtpTLSSTARTTLS
}
if port == 465 {
return smtpTLSImplicit
}
return smtpTLSSTARTTLS
}
// SMTPMailer sends mail through an SMTP relay using go-mail. When a username is
// set it authenticates, auto-discovering the strongest mechanism the relay
// advertises; otherwise it relays unauthenticated.
type SMTPMailer struct { type SMTPMailer struct {
cfg SMTPConfig cfg SMTPConfig
} }
@@ -39,29 +105,55 @@ func NewSMTPMailer(cfg SMTPConfig) SMTPMailer {
return SMTPMailer{cfg: cfg} return SMTPMailer{cfg: cfg}
} }
// Send delivers a plain-text UTF-8 message to to via the configured relay. // Send delivers a UTF-8 message to msg.To via the configured relay. When msg.HTML
func (m SMTPMailer) Send(_ context.Context, to, subject, body string) error { // is set the message is multipart/alternative (plain text plus HTML); otherwise
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port) // it is plain text only.
var auth smtp.Auth func (m SMTPMailer) Send(ctx context.Context, msg Message) error {
if m.cfg.Username != "" { port, err := strconv.Atoi(m.cfg.Port)
auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host) if err != nil {
return fmt.Errorf("account: invalid SMTP port %q: %w", m.cfg.Port, err)
} }
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, message(m.cfg.From, to, subject, body)); err != nil { opts := []mail.Option{mail.WithPort(port), mail.WithTimeout(smtpDialTimeout)}
return fmt.Errorf("account: send mail to %s: %w", to, err) if m.cfg.tlsMode(port) == smtpTLSImplicit {
opts = append(opts, mail.WithSSL())
} else {
opts = append(opts, mail.WithTLSPortPolicy(mail.TLSMandatory))
}
if m.cfg.Username != "" {
opts = append(opts,
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover),
mail.WithUsername(m.cfg.Username),
mail.WithPassword(m.cfg.Password),
)
}
client, err := mail.NewClient(m.cfg.Host, opts...)
if err != nil {
return fmt.Errorf("account: build mail client: %w", err)
}
out := mail.NewMsg()
from := m.cfg.From
if msg.From != "" {
from = msg.From
}
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)
out.SetBodyString(mail.TypeTextPlain, msg.Text)
if msg.HTML != "" {
out.AddAlternativeString(mail.TypeTextHTML, msg.HTML)
}
if err := client.DialAndSendWithContext(ctx, out); err != nil {
return fmt.Errorf("account: send mail to %s: %w", msg.To, err)
} }
return nil return nil
} }
// message renders a minimal RFC 5322 plain-text email.
func message(from, to, subject, body string) []byte {
return []byte("From: " + from + "\r\n" +
"To: " + to + "\r\n" +
"Subject: " + subject + "\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"\r\n" + body + "\r\n")
}
// LogMailer logs the message instead of sending it. It is the default when no // LogMailer logs the message instead of sending it. It is the default when no
// SMTP relay is configured and is intended for development only: it logs the body, // SMTP relay is configured and is intended for development only: it logs the body,
// which carries the confirm-code, so it must not be used in production. // which carries the confirm-code, so it must not be used in production.
@@ -74,11 +166,12 @@ func NewLogMailer(log *zap.Logger) LogMailer {
return LogMailer{log: log} return LogMailer{log: log}
} }
// Send logs the message at info level and reports success. // Send logs the message at info level and reports success. It logs the plain-text
func (m LogMailer) Send(_ context.Context, to, subject, body string) error { // body only (which carries the confirm-code); the HTML alternative is omitted.
func (m LogMailer) Send(_ context.Context, msg Message) error {
if m.log != nil { if m.log != nil {
m.log.Info("email not sent (log mailer)", m.log.Info("email not sent (log mailer)",
zap.String("to", to), zap.String("subject", subject), zap.String("body", body)) zap.String("to", msg.To), zap.String("subject", msg.Subject), zap.String("body", msg.Text))
} }
return nil return nil
} }
+51
View File
@@ -0,0 +1,51 @@
package account
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
// alone cannot classify.
func TestSMTPTLSMode(t *testing.T) {
cases := []struct {
name string
tls string
port int
want string
}{
{"explicit ssl on a custom port (Selectel 1127)", "ssl", 1127, smtpTLSImplicit},
{"explicit starttls on a custom port (Selectel 1126)", "starttls", 1126, smtpTLSSTARTTLS},
{"tls is an alias for ssl", "TLS", 2525, smtpTLSImplicit},
{"empty derives implicit TLS on 465", "", 465, smtpTLSImplicit},
{"empty derives STARTTLS on 587", "", 587, smtpTLSSTARTTLS},
{"an unknown value falls back to the port heuristic", "bogus", 465, smtpTLSImplicit},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := (SMTPConfig{TLS: c.tls}).tlsMode(c.port); got != c.want {
t.Errorf("tlsMode(TLS=%q, port=%d) = %q, want %q", c.tls, c.port, got, c.want)
}
})
}
}
+60
View File
@@ -101,6 +101,66 @@ func validateVariantPreferences(prefs []string) ([]string, error) {
return out, nil return out, nil
} }
// variantSeedPrefix marks a Telegram start-param payload that seeds a brand-new
// account's variant preferences (e.g. "verudit_ru-scrabble_en"): the prefix, then the
// canonical variant labels joined by "-". It is deliberately distinct from the routing
// deep links (g/i/f; see platform/telegram .../deeplink) so the client's start-param
// router falls through to the lobby for it.
const variantSeedPrefix = "v"
// SeedVariantsFromStartParam decodes a promo deep-link start-param into the variant
// preference set to seed onto a brand-new account: the variantSeedPrefix followed by
// the canonical variant labels joined by "-" (e.g. "verudit_ru-scrabble_en"). It
// returns nil for any payload that is not a variant-seed link or that fails validation
// against the known variants, so a malformed, empty or unrelated start-param simply
// leaves the account on its default preferences rather than failing the login.
func SeedVariantsFromStartParam(startParam string) []string {
if !strings.HasPrefix(startParam, variantSeedPrefix) {
return nil
}
body := strings.TrimPrefix(startParam, variantSeedPrefix)
if body == "" {
return nil
}
prefs, err := validateVariantPreferences(strings.Split(body, "-"))
if err != nil {
return nil
}
return prefs
}
// SetVariantPreferences overwrites only the variant-preference set of the account,
// cleaning it to a deduplicated, canonically ordered subset of the known variants
// (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it
// reports ErrNotFound when no account matches id. It is the narrow counterpart to
// UpdateProfile used to seed a promo-onboarded account's variants at first contact
// without disturbing its other profile fields.
func (s *Store) SetVariantPreferences(ctx context.Context, id uuid.UUID, prefs []string) (Account, error) {
clean, err := validateVariantPreferences(prefs)
if err != nil {
return Account{}, err
}
stmt := table.Accounts.UPDATE(
table.Accounts.VariantPreferences, table.Accounts.UpdatedAt,
).SET(
// clean is validated against the closed knownVariants set; bind as a text[]
// parameter (lib/pq encodes the array, the cast pins the column type), mirroring
// UpdateProfile.
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(clean)}),
postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Account{}, ErrNotFound
}
return Account{}, fmt.Errorf("account: set variant preferences %s: %w", id, err)
}
return modelToAccount(row), nil
}
// UpdateProfile validates and overwrites the editable fields of the account, then // UpdateProfile validates and overwrites the editable fields of the account, then
// returns the stored row. It reports ErrInvalidProfile for a bad language, // returns the stored row. It reports ErrInvalidProfile for a bad language,
// timezone or display name and ErrNotFound when no account matches id. // timezone or display name and ErrNotFound when no account matches id.
+60 -6
View File
@@ -9,8 +9,9 @@ import (
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the // TestTelegramSeed covers the pure mapping from Telegram launch fields to the
// create-time account seed: supported-language detection (bare and region-tagged), // create-time account seed: supported-language detection (bare and region-tagged),
// the first-name / username display-name precedence, and the sanitization that // the real-name → @username (verbatim) → placeholder display-name precedence, and
// strips disallowed characters (emoji, digits, punctuation) to the editable format. // the sanitization of the real name (emoji, digits, punctuation stripped to the
// editable format). The username, when used, is kept verbatim.
func TestTelegramSeed(t *testing.T) { func TestTelegramSeed(t *testing.T) {
cases := map[string]struct { cases := map[string]struct {
languageCode, username, firstName string languageCode, username, firstName string
@@ -28,6 +29,7 @@ func TestTelegramSeed(t *testing.T) {
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"}, "punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"}, "digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
"garbage to username": {"en", "good", "123!@#", "en", "good"}, "garbage to username": {"en", "good", "123!@#", "en", "good"},
"username verbatim": {"en", "co_ol99", "🎮🎮", "en", "co_ol99"},
} }
for name, tc := range cases { for name, tc := range cases {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
@@ -49,10 +51,10 @@ func TestTelegramSeedPlaceholder(t *testing.T) {
languageCode, username, firstName string languageCode, username, firstName string
wantRe string wantRe string
}{ }{
"en empty": {"en", "", "", `^Player-\d{5}$`}, "en empty": {"en", "", "", `^Player-\d{5}$`},
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`}, "ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
"default en": {"fr", "", "", `^Player-\d{5}$`}, "default en": {"fr", "", "", `^Player-\d{5}$`},
"both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`}, "name garbage, no username": {"ru", "", "!!!", `^Игрок-\d{5}$`},
} }
for name, tc := range cases { for name, tc := range cases {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
@@ -73,3 +75,55 @@ func TestTelegramSeedTruncatesLongName(t *testing.T) {
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName) t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
} }
} }
// TestVKSeed covers the pure mapping from VK launch fields to the create-time account
// seed: supported-language detection from vk_language (bare and region-tagged) and the
// display name sanitized from the client-supplied name. Unlike Telegram there is no
// @username fallback — VK provides only the name.
func TestVKSeed(t *testing.T) {
cases := map[string]struct {
languageCode, displayName string
wantLang, wantName string
}{
"ru bare": {"ru", "Иван", "ru", "Иван"},
"en region-tagged": {"en-US", "John", "en", "John"},
"full name kept": {"ru", "Иван Петров", "ru", "Иван Петров"},
"unknown language": {"uk", "Тарас", "", "Тарас"},
"empty language": {"", "Neo", "", "Neo"},
"trimmed": {" RU ", " Anna ", "ru", "Anna"},
"emoji stripped": {"en", "🎮Kaya🎮", "en", "Kaya"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := vkSeed(tc.languageCode, tc.displayName)
if got.preferredLanguage != tc.wantLang {
t.Errorf("preferredLanguage = %q, want %q", got.preferredLanguage, tc.wantLang)
}
if got.displayName != tc.wantName {
t.Errorf("displayName = %q, want %q", got.displayName, tc.wantName)
}
})
}
}
// TestVKSeedPlaceholder checks a VK name with no usable letters falls back to a
// generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN").
func TestVKSeedPlaceholder(t *testing.T) {
cases := map[string]struct {
languageCode, displayName string
wantRe string
}{
"en empty": {"en", "", `^Player-\d{5}$`},
"ru empty": {"ru", "", `^Игрок-\d{5}$`},
"default en": {"uk", "", `^Player-\d{5}$`},
"name garbage": {"ru", "123!@#", `^Игрок-\d{5}$`},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := vkSeed(tc.languageCode, tc.displayName).displayName
if !regexp.MustCompile(tc.wantRe).MatchString(got) {
t.Errorf("displayName = %q, want match %s", got, tc.wantRe)
}
})
}
}
+66
View File
@@ -0,0 +1,66 @@
package account
import (
"sync"
"time"
)
// SendLimiter throttles confirm-code sends per recipient address: it enforces a
// minimum cooldown between two sends and a cap over a rolling hour. It guards against
// email bombing and protects the relay's own quota. State is in-memory (per process,
// reset on restart) and keyed by the normalised recipient address, which is adequate
// for the single-instance backend. Safe for concurrent use.
type SendLimiter struct {
mu sync.Mutex
cooldown time.Duration
perHour int
now func() time.Time
sends map[string][]time.Time
}
// NewSendLimiter returns a SendLimiter allowing at most one send per cooldown and at
// most perHour sends over any rolling hour, to the same recipient.
func NewSendLimiter(cooldown time.Duration, perHour int) *SendLimiter {
return &SendLimiter{
cooldown: cooldown,
perHour: perHour,
now: func() time.Time { return time.Now() },
sends: make(map[string][]time.Time),
}
}
// Allow reports whether a send to key is permitted now, recording the send when it is.
// It is denied when the last send was within the cooldown or the rolling-hour cap is
// already reached.
func (l *SendLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
cutoff := now.Add(-time.Hour)
kept := l.sends[key][:0]
for _, t := range l.sends[key] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if n := len(kept); n > 0 && now.Sub(kept[n-1]) < l.cooldown {
l.set(key, kept)
return false
}
if len(kept) >= l.perHour {
l.set(key, kept)
return false
}
l.set(key, append(kept, now))
return true
}
// set stores the retained send times for key, dropping the entry entirely once empty
// so the map stays bounded to recipients active within the last hour.
func (l *SendLimiter) set(key string, times []time.Time) {
if len(times) == 0 {
delete(l.sends, key)
return
}
l.sends[key] = times
}
@@ -0,0 +1,43 @@
package account
import (
"testing"
"time"
)
// TestSendLimiter checks the per-recipient cooldown and the rolling-hour cap, and
// that recipients are throttled independently.
func TestSendLimiter(t *testing.T) {
base := time.Now()
now := base
l := NewSendLimiter(time.Minute, 3)
l.now = func() time.Time { return now }
if !l.Allow("a") {
t.Fatal("send 1 should be allowed")
}
if l.Allow("a") {
t.Fatal("immediate resend must be blocked by the cooldown")
}
if !l.Allow("b") {
t.Fatal("a different recipient is independent")
}
now = base.Add(time.Minute)
if !l.Allow("a") {
t.Fatal("send 2 after the cooldown should be allowed")
}
now = base.Add(2 * time.Minute)
if !l.Allow("a") {
t.Fatal("send 3 should be allowed")
}
now = base.Add(3 * time.Minute)
if l.Allow("a") {
t.Fatal("send 4 within the hour must be blocked by the cap")
}
now = base.Add(time.Hour + time.Minute)
if !l.Allow("a") {
t.Fatal("after the rolling hour the cap resets")
}
}
+224
View File
@@ -0,0 +1,224 @@
package account
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// RetentionTTL bounds how long the account-deletion legal dossier is kept before the
// reaper purges it: two years from the detach/deletion event (owner policy, 2026-07-03).
const RetentionTTL = 2 * 365 * 24 * time.Hour
// Reasons recorded on a retained_identities row: what detached the credential from its
// account (unlink / email change / account deletion here; an account merge that drops a
// same-kind colliding identity writes reason "merge" from the accountmerge package). The
// row is written just before the live identities row is removed, preserving the legal
// dossier (which email/vk/tg was linked, and when) even as the identity frees for reuse.
// See docs/ARCHITECTURE.md §9.1.
const (
retainUnlink = "unlink"
retainChange = "change"
retainDelete = "delete"
)
// retainIdentityTx appends a retention-journal row for one identity being detached, inside
// tx. linkedAt is the identity's original creation time; detached_at defaults to now(). It
// must run in the same transaction as the identity removal, so the dossier and the live
// state can never diverge.
func retainIdentityTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind, externalID string, confirmed bool, linkedAt time.Time, reason string) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new retained id: %w", err)
}
ins := table.RetainedIdentities.INSERT(
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
table.RetainedIdentities.Reason,
).VALUES(id, accountID, kind, externalID, confirmed, linkedAt, reason)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("account: retain identity (%s, %s): %w", kind, externalID, err)
}
return nil
}
// StampLastLogin records the account's last cold-load time and client IP, but only when
// the stored value is missing or older than an hour — so it costs at most one write per
// account per hour (its caller, the profile fetch, runs once per cold app-load). It is a
// best-effort audit signal that feeds the account-deletion dossier.
func (s *Store) StampLastLogin(ctx context.Context, accountID uuid.UUID, ip string) error {
now := time.Now().UTC()
upd := table.Accounts.UPDATE(table.Accounts.LastLoginAt, table.Accounts.LastLoginIP).
SET(postgres.TimestampzT(now), postgres.String(ip)).
WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
AND(
table.Accounts.LastLoginAt.IS_NULL().
OR(table.Accounts.LastLoginAt.LT(postgres.TimestampzT(now.Add(-time.Hour)))),
),
)
if _, err := upd.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: stamp last login %s: %w", accountID, err)
}
return nil
}
// ReapExpiredRetention purges retention data whose event is older than cutoff: every
// retained_identities row by its detached_at (covering unlink/change on live accounts as
// well as deleted ones), plus — for accounts tombstoned before cutoff — the retained
// feedback thread and the dossier PII (deleted_display_name, last_login_ip). Chat is kept
// (a shared game artifact), and the tombstone account row itself stays (its no-cascade
// foreign keys). It returns how many journal rows and feedback messages were removed.
func (s *Store) ReapExpiredRetention(ctx context.Context, cutoff time.Time) (identities, feedback int64, err error) {
cut := postgres.TimestampzT(cutoff)
delJournal := table.RetainedIdentities.DELETE().
WHERE(table.RetainedIdentities.DetachedAt.LT(cut))
res, err := delJournal.ExecContext(ctx, s.db)
if err != nil {
return 0, 0, fmt.Errorf("account: reap retained identities: %w", err)
}
identities, _ = res.RowsAffected()
expired := postgres.SELECT(table.Accounts.AccountID).
FROM(table.Accounts).
WHERE(table.Accounts.DeletedAt.IS_NOT_NULL().AND(table.Accounts.DeletedAt.LT(cut)))
delFeedback := table.FeedbackMessages.DELETE().
WHERE(table.FeedbackMessages.AccountID.IN(expired))
fbRes, err := delFeedback.ExecContext(ctx, s.db)
if err != nil {
return identities, 0, fmt.Errorf("account: reap deleted feedback: %w", err)
}
feedback, _ = fbRes.RowsAffected()
clearPII := table.Accounts.UPDATE(table.Accounts.DeletedDisplayName, table.Accounts.LastLoginIP).
SET(postgres.NULL, postgres.NULL).
WHERE(
table.Accounts.DeletedAt.IS_NOT_NULL().
AND(table.Accounts.DeletedAt.LT(cut)).
AND(table.Accounts.DeletedDisplayName.IS_NOT_NULL().
OR(table.Accounts.LastLoginIP.IS_NOT_NULL())),
)
if _, err := clearPII.ExecContext(ctx, s.db); err != nil {
return identities, feedback, fmt.Errorf("account: clear expired dossier PII: %w", err)
}
return identities, feedback, nil
}
// RetainedIdentity is one row of the retention journal, for the admin dossier.
type RetainedIdentity struct {
Kind string
ExternalID string
Reason string
Confirmed bool
LinkedAt time.Time
DetachedAt time.Time
}
// RetainedIdentities returns the account's retention-journal rows (the legal dossier of
// detached credentials), newest detach first, for the admin console.
func (s *Store) RetainedIdentities(ctx context.Context, accountID uuid.UUID) ([]RetainedIdentity, error) {
var rows []model.RetainedIdentities
err := postgres.SELECT(table.RetainedIdentities.AllColumns).
FROM(table.RetainedIdentities).
WHERE(table.RetainedIdentities.AccountID.EQ(postgres.UUID(accountID))).
ORDER_BY(table.RetainedIdentities.DetachedAt.DESC()).
QueryContext(ctx, s.db, &rows)
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
return nil, fmt.Errorf("account: retained identities %s: %w", accountID, err)
}
out := make([]RetainedIdentity, 0, len(rows))
for _, r := range rows {
out = append(out, RetainedIdentity{
Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
Confirmed: r.Confirmed, LinkedAt: r.LinkedAt, DetachedAt: r.DetachedAt,
})
}
return out, nil
}
// DeletionInfo is a tombstoned account's dossier header, for the admin console.
type DeletionInfo struct {
DeletedAt *time.Time
DeletedDisplayName string
LastLoginAt *time.Time
LastLoginIP string
}
// DeletionInfo reads the account's deletion tombstone + last-login dossier fields.
func (s *Store) DeletionInfo(ctx context.Context, accountID uuid.UUID) (DeletionInfo, error) {
var row model.Accounts
err := postgres.SELECT(
table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName,
table.Accounts.LastLoginAt, table.Accounts.LastLoginIP,
).FROM(table.Accounts).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID))).
QueryContext(ctx, s.db, &row)
if err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return DeletionInfo{}, ErrNotFound
}
return DeletionInfo{}, fmt.Errorf("account: deletion info %s: %w", accountID, err)
}
info := DeletionInfo{DeletedAt: row.DeletedAt, LastLoginAt: row.LastLoginAt}
if row.DeletedDisplayName != nil {
info.DeletedDisplayName = *row.DeletedDisplayName
}
if row.LastLoginIP != nil {
info.LastLoginIP = *row.LastLoginIP
}
return info, nil
}
// RetentionReaper periodically purges expired account-deletion retention data via
// Store.ReapExpiredRetention, mirroring GuestReaper: one background goroutine started once
// from main.
type RetentionReaper struct {
store *Store
ttl time.Duration
clock func() time.Time
log *zap.Logger
}
// NewRetentionReaper constructs a reaper purging retention data older than ttl. log may be
// nil.
func NewRetentionReaper(store *Store, ttl time.Duration, log *zap.Logger) *RetentionReaper {
if log == nil {
log = zap.NewNop()
}
return &RetentionReaper{
store: store,
ttl: ttl,
clock: func() time.Time { return time.Now().UTC() },
log: log,
}
}
// Run purges expired retention data on each tick until ctx is cancelled.
func (r *RetentionReaper) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
idn, fb, err := r.store.ReapExpiredRetention(ctx, r.clock().Add(-r.ttl))
if err != nil {
r.log.Warn("retention reap failed", zap.Error(err))
} else if idn > 0 || fb > 0 {
r.log.Info("reaped expired retention", zap.Int64("identities", idn), zap.Int64("feedback", fb))
}
}
}
}
+45 -13
View File
@@ -19,6 +19,9 @@ type UserListItem struct {
PreferredLanguage string PreferredLanguage string
IsGuest bool IsGuest bool
IsRobot bool IsRobot bool
// IsDeleted marks a tombstoned account (deleted_at set), shown as a badge — a search
// spans both lists, so a result can be either live or deleted.
IsDeleted bool
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown // FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
// as a badge in the console list. // as a badge in the console list.
FlaggedHighRateAt time.Time FlaggedHighRateAt time.Time
@@ -26,13 +29,17 @@ type UserListItem struct {
} }
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the // UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = // non-robot "people"); Deleted selects tombstoned accounts (every other scope hides them);
// one char) matched case-insensitively against the display name / any identity's external // NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = one char) matched
// id. An empty mask means no filter on that field. // case-insensitively against the display name / any identity's external id; EmailExact is a
// strict (exact) match against an account's email identity. An empty value means no filter
// on that field.
type UserFilter struct { type UserFilter struct {
Robots bool Robots bool
Deleted bool
NameMask string NameMask string
ExternalIDMask string ExternalIDMask string
EmailExact string
} }
// robotExists is the correlated subquery testing whether account a is a robot. // robotExists is the correlated subquery testing whether account a is a robot.
@@ -51,17 +58,42 @@ func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error)
return ok, nil return ok, nil
} }
// userListWhere builds the shared WHERE clause and its positional args (from $1). // userListWhere builds the shared WHERE clause and its positional args (from $1). On the
// Robots tab it lists/searches robots only. Otherwise a search (any of the name /
// external-id / email filters) spans live and deleted people alike — never robots — so the
// operator finds a match from one query regardless of the People / Deleted tab; the search
// also looks in the retention journal, so a deleted account is still found by the email /
// external id it held (those rows moved from identities to retained_identities on deletion)
// and by its retained real name. With no search, the People / Deleted tab scope applies.
func userListWhere(f UserFilter) (string, []any) { func userListWhere(f UserFilter) (string, []any) {
args := []any{f.Robots} name := LikePattern(f.NameMask)
where := robotExists + ` = $1` ext := LikePattern(f.ExternalIDMask)
if name := LikePattern(f.NameMask); name != "" { email := strings.ToLower(strings.TrimSpace(f.EmailExact))
args = append(args, name) searching := name != "" || ext != "" || email != ""
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
var args []any
var where string
switch {
case f.Robots:
where = robotExists + ` = true`
case searching:
where = robotExists + ` = false`
case f.Deleted:
where = robotExists + ` = false AND a.deleted_at IS NOT NULL`
default:
where = robotExists + ` = false AND a.deleted_at IS NULL`
} }
if ext := LikePattern(f.ExternalIDMask); ext != "" { if name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND (a.display_name ILIKE $%d ESCAPE '\' OR a.deleted_display_name ILIKE $%d ESCAPE '\')`, len(args), len(args))
}
if ext != "" {
args = append(args, ext) args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args)) where += fmt.Sprintf(` AND (EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\') OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.external_id ILIKE $%d ESCAPE '\'))`, len(args), len(args))
}
if email != "" {
args = append(args, email)
where += fmt.Sprintf(` AND (EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d) OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.kind = 'email' AND r.external_id = $%d))`, len(args), len(args))
} }
return where, args return where, args
} }
@@ -69,7 +101,7 @@ func userListWhere(f UserFilter) (string, []any) {
// ListUsers returns the filtered admin user list, newest first, paginated. // ListUsers returns the filtered admin user list, newest first, paginated.
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) { func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
where, args := userListWhere(f) where, args := userListWhere(f)
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot, (a.deleted_at IS NOT NULL) AS is_deleted
FROM backend.accounts a WHERE ` + where + FROM backend.accounts a WHERE ` + where +
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2) fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset) args = append(args, limit, offset)
@@ -82,7 +114,7 @@ FROM backend.accounts a WHERE ` + where +
for rows.Next() { for rows.Next() {
var it UserListItem var it UserListItem
var flagged sql.NullTime var flagged sql.NullTime
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil { if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot, &it.IsDeleted); err != nil {
return nil, fmt.Errorf("account: scan user: %w", err) return nil, fmt.Errorf("account: scan user: %w", err)
} }
if flagged.Valid { if flagged.Valid {
@@ -0,0 +1,37 @@
package account
import (
"slices"
"testing"
)
// TestSeedVariantsFromStartParam covers decoding a promo deep-link start-param into the
// variant-preference set to seed: a valid "v"-prefixed, "-"-joined label list is cleaned
// to the canonical order and deduplicated, while anything that is not a variant-seed link
// or that names an unknown variant yields nil (leaving the account on its defaults).
func TestSeedVariantsFromStartParam(t *testing.T) {
tests := []struct {
name string
param string
want []string
}{
{"english promo", "verudit_ru-scrabble_en", []string{"erudit_ru", "scrabble_en"}},
{"single variant", "vscrabble_en", []string{"scrabble_en"}},
{"canonical order regardless of payload order", "vscrabble_en-erudit_ru", []string{"erudit_ru", "scrabble_en"}},
{"deduplicated", "verudit_ru-erudit_ru", []string{"erudit_ru"}},
{"empty", "", nil},
{"prefix only", "v", nil},
{"routing game link is not a seed", "g0190abcd", nil},
{"friend code link is not a seed", "f123456", nil},
{"unknown variant rejected", "vscrabble_de", nil},
{"one unknown label rejects the whole set", "verudit_ru-scrabble_de", nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SeedVariantsFromStartParam(tc.param)
if !slices.Equal(got, tc.want) {
t.Errorf("SeedVariantsFromStartParam(%q) = %v, want %v", tc.param, got, tc.want)
}
})
}
}
+223
View File
@@ -0,0 +1,223 @@
// Package accountdelete deactivates an account as legal retention, not erasure: it keeps
// the account row as a tombstone (its chat/complaint foreign keys have no cascade, so a
// hard delete is impossible) while journalling and freeing the account's credentials,
// anonymising the live surfaces, and dropping the account's own social/ephemeral rows.
// The retained_identities journal plus the tombstone (deleted_at, deleted_display_name,
// last_login_at/ip) form the admin/legal dossier; messages are deliberately kept. Session
// revocation and active-game forfeit are orchestrated one layer up (they need the session
// cache and the game service). See docs/ARCHITECTURE.md §9.1 and the retention TTL reaper.
package accountdelete
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// AnonymizedName is the label a deleted account shows to opponents. Display names are
// stored strings resolved identically for every viewer (no per-viewer localisation in this
// codebase), so a single canonical label is used. The brackets are deliberate: the
// editable-name rule (account.displayNameRe) forbids them, so a live player can never set a
// name that impersonates a deleted account.
const AnonymizedName = "[Deleted]"
// retainDelete is the retained_identities reason written when a credential is journalled
// because its account is being deleted.
const retainDelete = "delete"
// Deleter performs the SQL-atomic part of account deletion over a Postgres handle.
type Deleter struct {
db *sql.DB
now func() time.Time
}
// NewDeleter constructs a Deleter over db.
func NewDeleter(db *sql.DB) *Deleter {
return &Deleter{db: db, now: func() time.Time { return time.Now().UTC() }}
}
// AnonymizeAndTombstone retires accountID atomically: it journals every live identity into
// retained_identities (reason=delete) then removes them so the credentials free for reuse,
// snapshots the real display name into deleted_display_name and scrubs the live one to
// AnonymizedName, sets deleted_at, anonymises the account's game-seat snapshots, and drops
// its friendships, blocks, invitations, friend codes, drafts and pending codes. Chat,
// feedback and complaints are kept (the surviving tombstone keeps their no-cascade foreign
// keys valid). It is idempotent-safe on an already-tombstoned account (re-journalling
// nothing, since the identities are already gone).
func (d *Deleter) AnonymizeAndTombstone(ctx context.Context, accountID uuid.UUID) error {
now := d.now()
return withTx(ctx, d.db, func(tx *sql.Tx) error {
if err := journalAndDropIdentities(ctx, tx, accountID, now); err != nil {
return err
}
if err := tombstone(ctx, tx, accountID, now); err != nil {
return err
}
if _, err := table.GamePlayers.UPDATE(table.GamePlayers.DisplayName).
SET(postgres.String(AnonymizedName)).
WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(accountID))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: anonymise seats: %w", err)
}
return dropSocialAndEphemerals(ctx, tx, accountID)
})
}
// dropAllRobotGamesSQL deletes every game in which the account plays and no other seat is a
// human — a robot seat is one whose account holds a 'robot' identity, so this covers both
// honest vs-AI games and disguised auto-match substitutes. The game rows are deleted; their
// moves/chat/players/complaints fall away through ON DELETE CASCADE.
const dropAllRobotGamesSQL = `
DELETE FROM games g
WHERE EXISTS (
SELECT 1 FROM game_players p WHERE p.game_id = g.game_id AND p.account_id = $1
) AND NOT EXISTS (
SELECT 1 FROM game_players o
WHERE o.game_id = g.game_id AND o.account_id <> $1
AND NOT EXISTS (
SELECT 1 FROM identities i WHERE i.account_id = o.account_id AND i.kind = 'robot'
)
)`
// DropAllRobotGames deletes the account's games that have no human opponent (solo vs-AI or
// auto-match-robot games), returning how many were removed. Games with any human seat are
// kept — their seat is anonymised by AnonymizeAndTombstone instead. Run it after the
// account's active games are resigned, so no live game is removed under the robot driver.
func (d *Deleter) DropAllRobotGames(ctx context.Context, accountID uuid.UUID) (int64, error) {
res, err := d.db.ExecContext(ctx, dropAllRobotGamesSQL, accountID)
if err != nil {
return 0, fmt.Errorf("accountdelete: drop all-robot games: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("accountdelete: dropped games count: %w", err)
}
return n, nil
}
// journalAndDropIdentities copies the account's live identities into the retention journal
// (reason=delete) and then removes them, freeing each (kind, external_id) for reuse.
func journalAndDropIdentities(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error {
var ids []model.Identities
err := postgres.SELECT(table.Identities.AllColumns).
FROM(table.Identities).
WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
QueryContext(ctx, tx, &ids)
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountdelete: load identities: %w", err)
}
for _, id := range ids {
rid, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("accountdelete: new retained id: %w", err)
}
ins := table.RetainedIdentities.INSERT(
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
table.RetainedIdentities.DetachedAt, table.RetainedIdentities.Reason,
).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, now, retainDelete)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: retain identity %s: %w", id.Kind, err)
}
}
if _, err := table.Identities.DELETE().
WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete identities: %w", err)
}
return nil
}
// tombstone marks the account deleted, snapshotting the real display name into
// deleted_display_name (evaluated from the old row) before scrubbing the live one.
func tombstone(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, now time.Time) error {
upd := table.Accounts.UPDATE(
table.Accounts.DeletedAt, table.Accounts.DeletedDisplayName,
table.Accounts.DisplayName, table.Accounts.UpdatedAt,
).SET(
postgres.TimestampzT(now), table.Accounts.DisplayName,
postgres.String(AnonymizedName), postgres.TimestampzT(now),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(accountID)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: tombstone account: %w", err)
}
return nil
}
// dropSocialAndEphemerals removes the account's own friendships, blocks, invitations
// (as inviter and as invitee), friend codes, drafts and pending confirm-codes. These are
// the deleting user's private data with no dossier value; chat and feedback are kept.
func dropSocialAndEphemerals(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) error {
id := postgres.UUID(accountID)
// Friendships and blocks are two-account edges keyed on either endpoint.
if _, err := table.Friendships.DELETE().
WHERE(table.Friendships.RequesterID.EQ(id).OR(table.Friendships.AddresseeID.EQ(id))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete friendships: %w", err)
}
if _, err := table.Blocks.DELETE().
WHERE(table.Blocks.BlockerID.EQ(id).OR(table.Blocks.BlockedID.EQ(id))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete blocks: %w", err)
}
// Invitations: drop the account's invitee rows, then its own invitations' invitees and
// the invitations themselves (children first, to respect the foreign key).
if _, err := table.GameInvitationInvitees.DELETE().
WHERE(table.GameInvitationInvitees.AccountID.EQ(id)).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete invitee rows: %w", err)
}
ownInvitations := postgres.SELECT(table.GameInvitations.InvitationID).
FROM(table.GameInvitations).
WHERE(table.GameInvitations.InviterID.EQ(id))
if _, err := table.GameInvitationInvitees.DELETE().
WHERE(table.GameInvitationInvitees.InvitationID.IN(ownInvitations)).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete own invitation invitees: %w", err)
}
if _, err := table.GameInvitations.DELETE().
WHERE(table.GameInvitations.InviterID.EQ(id)).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete invitations: %w", err)
}
// Ephemerals: friend codes, move drafts, pending confirm-codes.
if _, err := table.FriendCodes.DELETE().
WHERE(table.FriendCodes.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete friend codes: %w", err)
}
if _, err := table.GameDrafts.DELETE().
WHERE(table.GameDrafts.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete drafts: %w", err)
}
if _, err := table.EmailConfirmations.DELETE().
WHERE(table.EmailConfirmations.AccountID.EQ(id)).ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountdelete: delete confirmations: %w", err)
}
return nil
}
// withTx runs fn inside a transaction, committing on success and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("accountdelete: begin tx: %w", err)
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("accountdelete: commit tx: %w", err)
}
return nil
}
+79
View File
@@ -27,6 +27,11 @@ import (
// without taking a dependency on the game package. // without taking a dependency on the game package.
const statusActive = "active" const statusActive = "active"
// retainReasonMerge is the retained_identities.reason for a credential dropped by a merge
// collision (both accounts held the same kind). It mirrors the account package's retain
// reasons, kept local to avoid importing that package's unexported constants.
const retainReasonMerge = "merge"
// Friendship statuses, highest precedence first, mirroring internal/social. // Friendship statuses, highest precedence first, mirroring internal/social.
const ( const (
friendAccepted = "accepted" friendAccepted = "accepted"
@@ -75,6 +80,9 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil { if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
return err return err
} }
if err := dedupeIdentities(ctx, tx, primary, secondary); err != nil {
return err
}
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil { if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: identities: %w", err) return fmt.Errorf("accountmerge: identities: %w", err)
} }
@@ -300,6 +308,77 @@ func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col pos
return err return err
} }
// dedupeIdentities resolves a same-kind identity collision before the blanket identity
// reassign: when both accounts already hold an identity of the same kind (e.g. each has a
// confirmed email — reachable when two email-bearing accounts merge), the primary keeps
// its own and the secondary's is journaled to retained_identities (reason=merge) and
// removed. Without this the blanket reassign would leave the survivor with two identities
// of one kind (there is no per-account-kind unique on identities), which the profile and
// the retention dossier both treat as singular. Non-colliding identities are untouched and
// move with the blanket reassign.
func dedupeIdentities(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
var prows []model.Identities
if err := postgres.SELECT(table.Identities.Kind).
FROM(table.Identities).
WHERE(table.Identities.AccountID.EQ(postgres.UUID(primary))).
QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: primary identity kinds: %w", err)
}
occupied := make(map[string]struct{}, len(prows))
for _, r := range prows {
occupied[r.Kind] = struct{}{}
}
if len(occupied) == 0 {
return nil
}
var srows []model.Identities
if err := postgres.SELECT(
table.Identities.Kind, table.Identities.ExternalID,
table.Identities.Confirmed, table.Identities.CreatedAt,
).FROM(table.Identities).
WHERE(table.Identities.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: secondary identities: %w", err)
}
for _, s := range srows {
if _, dup := occupied[s.Kind]; !dup {
continue
}
if err := retainMergedIdentity(ctx, tx, secondary, s); err != nil {
return err
}
del := table.Identities.DELETE().WHERE(
table.Identities.AccountID.EQ(postgres.UUID(secondary)).
AND(table.Identities.Kind.EQ(postgres.String(s.Kind))).
AND(table.Identities.ExternalID.EQ(postgres.String(s.ExternalID))),
)
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: drop colliding %s identity: %w", s.Kind, err)
}
}
return nil
}
// retainMergedIdentity appends a retained_identities row for a secondary identity dropped
// by a merge collision (reason=merge), preserving it in the legal dossier. It mirrors
// account.retainIdentityTx, which is unexported; detached_at falls to the column default.
func retainMergedIdentity(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, id model.Identities) error {
rid, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("accountmerge: new retained id: %w", err)
}
ins := table.RetainedIdentities.INSERT(
table.RetainedIdentities.RetainedID, table.RetainedIdentities.AccountID,
table.RetainedIdentities.Kind, table.RetainedIdentities.ExternalID,
table.RetainedIdentities.Confirmed, table.RetainedIdentities.LinkedAt,
table.RetainedIdentities.Reason,
).VALUES(rid, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainReasonMerge)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: retain merged %s identity: %w", id.Kind, err)
}
return nil
}
// friendRank ranks a friendship status for dedupe precedence (higher wins). // friendRank ranks a friendship status for dedupe precedence (higher wins).
func friendRank(status string) int { func friendRank(status string) int {
switch status { switch status {
+115
View File
@@ -0,0 +1,115 @@
// Package adminalert emails the operator when new player feedback or word complaints
// arrive, coalescing a burst into a single digest per interval so a flood is one email,
// not N. It is inert unless an admin sender and recipient are configured. The sender is
// distinct from the user-facing confirm-code From, and the recipient may be several
// comma-separated addresses (the mailer splits them).
package adminalert
import (
"context"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"scrabble/backend/internal/account"
)
// FeedbackCounter counts feedback created since a time (satisfied by feedback.Service).
type FeedbackCounter interface {
CountSince(ctx context.Context, since time.Time) (int, error)
}
// ComplaintCounter counts word complaints filed since a time (satisfied by game.Service).
type ComplaintCounter interface {
CountComplaintsSince(ctx context.Context, since time.Time) (int, error)
}
// Notifier polls for new feedback and complaints and emails the operator a digest.
type Notifier struct {
mailer account.Mailer
feedback FeedbackCounter
complaints ComplaintCounter
from string
to string
clock func() time.Time
log *zap.Logger
last time.Time
}
// New constructs a Notifier. from and to are the alert sender and recipient(s); log may be
// nil. The watermark starts at "now", so only items arriving after start-up are reported. The
// digest deliberately carries no admin-console link — an admin URL must never travel in an
// email, where a mail provider could cache or index it.
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier {
if log == nil {
log = zap.NewNop()
}
return &Notifier{
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to,
clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(),
}
}
// Run polls on each tick until ctx is cancelled.
func (n *Notifier) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
n.tick(ctx)
}
}
}
// tick counts what arrived since the last watermark and, if anything did, emails one
// digest. The watermark only advances after a successful send (or a quiet tick), so a
// transient send failure is retried on the next tick — the counts simply grow.
func (n *Notifier) tick(ctx context.Context) {
now := n.clock()
fb, err := n.feedback.CountSince(ctx, n.last)
if err != nil {
n.log.Warn("admin alert: count feedback failed", zap.Error(err))
return
}
cp, err := n.complaints.CountComplaintsSince(ctx, n.last)
if err != nil {
n.log.Warn("admin alert: count complaints failed", zap.Error(err))
return
}
if fb == 0 && cp == 0 {
n.last = now
return
}
if err := n.mailer.Send(ctx, n.digest(fb, cp)); err != nil {
n.log.Warn("admin alert: send failed", zap.Error(err))
return
}
n.log.Info("admin alert sent", zap.Int("feedback", fb), zap.Int("complaints", cp))
n.last = now
}
// digest builds the operator alert email for fb new feedback and cp new complaints.
func (n *Notifier) digest(fb, cp int) account.Message {
var parts []string
if fb > 0 {
parts = append(parts, fmt.Sprintf("%d new feedback message(s)", fb))
}
if cp > 0 {
parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp))
}
summary := strings.Join(parts, ", ")
// No admin-console link in the body: an admin URL must never travel in an email (a mail
// provider could cache or index it). The operator opens the console directly.
text := summary + "."
return account.Message{
From: n.from,
To: n.to,
Subject: "Erudit — " + summary,
Text: text,
}
}
@@ -0,0 +1,57 @@
package adminalert
import (
"context"
"strings"
"testing"
"time"
"scrabble/backend/internal/account"
)
// The fakes ignore the watermark and return a fixed count, which is all the digest logic
// needs.
type fbCounter struct{ n int }
func (f fbCounter) CountSince(context.Context, time.Time) (int, error) { return f.n, nil }
type cpCounter struct{ n int }
func (c cpCounter) CountComplaintsSince(context.Context, time.Time) (int, error) { return c.n, nil }
type recordingMailer struct{ sent []account.Message }
func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
m.sent = append(m.sent, msg)
return nil
}
func TestNotifierSkipsWhenNothingNew(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", nil)
n.tick(context.Background())
if len(mailer.sent) != 0 {
t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent))
}
}
func TestNotifierDigestsNewItems(t *testing.T) {
mailer := &recordingMailer{}
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", nil)
n.tick(context.Background())
if len(mailer.sent) != 1 {
t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent))
}
msg := mailer.sent[0]
if msg.From != "alerts@erudit-game.ru" || msg.To != "op@x.ru, two@x.ru" {
t.Errorf("digest addressing = From %q To %q", msg.From, msg.To)
}
if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") {
t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject)
}
// The digest must never carry an admin-console link — an admin URL in an email is a leak
// (mail providers cache/index it).
if strings.Contains(msg.Text, "/_gm") || strings.Contains(strings.ToLower(msg.Text), "admin console") {
t.Errorf("digest body = %q, must not carry an admin-console link", msg.Text)
}
}
@@ -193,3 +193,24 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; } .replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; } .replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
.replay-log li.cur { color: var(--ink); font-weight: 600; } .replay-log li.cur { color: var(--ink); font-weight: 600; }
/* Banner colour override editor + live preview (banner_detail). The override
fieldsets group the enable toggle with the native colour swatches; the preview
renders a sample strip on both themes from those inputs (see the inline script). */
.ovr { border: 1px solid var(--line); border-radius: 6px; padding: 0.3rem 0.8rem 0.7rem; margin: 0.2rem 0; }
.ovr legend { padding: 0 0.3rem; font-size: 0.85rem; color: var(--ink); }
.ovr legend label { flex-direction: row; align-items: center; gap: 0.4rem; color: var(--ink); }
.ovr .note { margin: 0.2rem 0 0.4rem; }
.ovr .swatches { display: flex; flex-wrap: wrap; gap: 1rem; }
.ovr .swatches label { flex-direction: column; gap: 0.25rem; align-items: flex-start; }
.ovr input[type=color] { width: 3rem; height: 1.8rem; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); cursor: pointer; }
.ovr input[type=color]:disabled { opacity: 0.4; cursor: default; }
.ovr .hex { font-size: 0.72rem; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
.banner-preview { display: flex; flex-wrap: wrap; gap: 0.8rem; margin: 0.7rem 0 0.2rem; }
.banner-preview .bp { flex: 1 1 18rem; }
.banner-preview .bp-label { display: block; font-size: 0.75rem; color: var(--ink-dim); margin-bottom: 0.25rem; }
.ad-frame { padding: 0.7rem; border-radius: 6px; border: 1px solid var(--line); }
.ad-frame.light { background: #f4f6f9; }
.ad-frame.dark { background: #0f1420; }
.ad-sample { padding: 0.35rem 0.7rem; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ad-sample .ad-link { text-decoration: underline; }
@@ -24,6 +24,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"}, {"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"}, {"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", VKID: "494075"}, "vk.com/id494075"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
@@ -11,10 +11,72 @@
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label> <label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label> <label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label> <label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
<fieldset class="ovr">
<legend><label><input type="checkbox" name="urgent"{{if .Urgent}} checked{{end}}> Urgent</label></legend>
<p class="note">Shows to <em>everyone</em>, always — bypassing paid accounts, hint wallets and the no-banner role. While any urgent campaign is live it is the only thing the strip shows (other campaigns and the default are suppressed). For system alerts.</p>
</fieldset>
<fieldset class="ovr" data-ovr-group>
<legend><label><input type="checkbox" name="override_all_on" data-ovr="all"{{if .OverrideAllOn}} checked{{end}}> Colour override — all themes</label></legend>
<div class="swatches">
<label>Background <input type="color" name="override_bg" value="{{.AllBg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
<label>Text <input type="color" name="override_fg" value="{{.AllFg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
<label>Link <input type="color" name="override_link" value="{{.AllLink}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
</div>
</fieldset>
<fieldset class="ovr" data-ovr-group>
<legend><label><input type="checkbox" name="override_dark_on" data-ovr="dark"{{if .OverrideDarkOn}} checked{{end}}> Colour override — dark theme only</label></legend>
<div class="swatches">
<label>Background <input type="color" name="override_bg_dark" value="{{.DarkBg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
<label>Text <input type="color" name="override_fg_dark" value="{{.DarkFg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
<label>Link <input type="color" name="override_link_dark" value="{{.DarkLink}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
</div>
</fieldset>
{{end}} {{end}}
<div><button type="submit">Save</button></div> <div><button type="submit">Save</button></div>
</form> </form>
{{if not .IsDefault}} {{if not .IsDefault}}
<div class="banner-preview">
<div class="bp"><span class="bp-label">Light theme</span><div class="ad-frame light"><div class="ad-sample" id="prev-light"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
<div class="bp"><span class="bp-label">Dark theme</span><div class="ad-frame dark"><div class="ad-sample" id="prev-dark"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
</div>
<p class="note">Live preview of the strip on both themes. An empty override falls back to the neutral theme colours; the top/bottom border is derived from the background.</p>
<script>
(function(){
// Neutral fallbacks — mirror ui/src/app.css (--ad-bg / --text-muted / --accent).
var TOK={light:{bg:'#e3e7ee',fg:'#6b7280',link:'#2f6df6'},dark:{bg:'#272f3c',fg:'#9aa3b2',link:'#5b8cff'}};
function byName(n){return document.querySelector('[name="'+n+'"]');}
var allOn=byName('override_all_on'), darkOn=byName('override_dark_on');
if(!allOn||!darkOn){return;}
var f={ab:byName('override_bg'),af:byName('override_fg'),al:byName('override_link'),db:byName('override_bg_dark'),df:byName('override_fg_dark'),dl:byName('override_link_dark')};
var lightEl=document.getElementById('prev-light'), darkEl=document.getElementById('prev-dark');
function hexToRgb(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
function pad(x){x=Math.max(0,Math.min(255,Math.round(x))).toString(16);return x.length<2?'0'+x:x;}
function rgbToHex(r){return '#'+pad(r[0])+pad(r[1])+pad(r[2]);}
function mix(a,b,t){return [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];}
function lum(r){return (0.2126*r[0]+0.7152*r[1]+0.0722*r[2])/255;}
// Derived border: nudge the background 14% toward black on a light bg, toward white on a dark bg.
function border(bg){var r=hexToRgb(bg);return rgbToHex(mix(r, lum(r)>0.5?[0,0,0]:[255,255,255], 0.14));}
function paint(el,c){
el.style.background=c.bg; el.style.color=c.fg;
el.style.borderTop='1px solid '+border(c.bg); el.style.borderBottom='1px solid '+border(c.bg);
var a=el.querySelector('.ad-link'); if(a){a.style.color=c.link;}
}
function resolve(){
var light=allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.light;
var dark=darkOn.checked?{bg:f.db.value,fg:f.df.value,link:f.dl.value}
:(allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.dark);
paint(lightEl,light); paint(darkEl,dark);
document.querySelectorAll('.ovr .swatches label').forEach(function(lab){
var inp=lab.querySelector('input[type=color]'), hx=lab.querySelector('.hex');
if(inp&&hx){hx.textContent=inp.value;}
});
}
function toggleGroup(chk){chk.closest('fieldset').querySelectorAll('[data-ovr-color]').forEach(function(inp){inp.disabled=!chk.checked;});}
[allOn,darkOn].forEach(function(chk){chk.addEventListener('change',function(){toggleGroup(chk);resolve();});});
Object.keys(f).forEach(function(k){f[k].addEventListener('input',resolve);});
resolve();
})();
</script>
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')"> <form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
<button type="submit" class="danger">Delete campaign</button> <button type="submit" class="danger">Delete campaign</button>
</form> </form>
@@ -7,8 +7,9 @@
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li> <li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
<li><b>Channel</b> {{.Channel}}</li> <li><b>Channel</b> {{.Channel}}</li>
<li><b>Interface language</b> {{.InterfaceLanguage}}</li> <li><b>Interface language</b> {{.InterfaceLanguage}}</li>
<li><b>App version</b> {{if .Version}}<code>{{.Version}}</code>{{else}}<span class="note">unknown</span>{{end}}</li>
<li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li> <li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li>
<li><b>Filed</b> {{.CreatedAt}}</li> <li><b>Filed</b> {{.CreatedAt}} UTC &middot; browser {{if .CreatedAtBrowser}}{{.CreatedAtBrowser}} ({{.BrowserTZ}}){{else}}<span class="note">N/A</span>{{end}} &middot; user {{if .CreatedAtUser}}{{.CreatedAtUser}} ({{.UserTZ}}){{else}}<span class="note">N/A</span>{{end}}</li>
<li><b>State</b> {{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</li> <li><b>State</b> {{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</li>
{{if .Banned}}<li><b>Feedback</b> <span class="warn">sender is banned from feedback</span></li>{{end}} {{if .Banned}}<li><b>Feedback</b> <span class="warn">sender is banned from feedback</span></li>{{end}}
</ul> </ul>
@@ -101,6 +101,29 @@
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}} {{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
</tbody> </tbody>
</table> </table>
{{if .HasEmail}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/remove-email" onsubmit="return confirm('Erase the email identity from this account? The address will be freed.')">
<button type="submit">Erase email</button>
</form>
{{end}}
</section>
<section class="panel"><h2>Deletion &amp; retention</h2>
{{if .LastLoginAt}}<p class="note">Last login: {{.LastLoginAt}}{{if .LastLoginIP}} — <code>{{.LastLoginIP}}</code>{{end}}</p>{{end}}
{{if .Deleted}}<p><span class="warn">Deleted</span> at {{.DeletedAt}}{{if .DeletedName}} — was <code>{{.DeletedName}}</code>{{end}}</p>{{end}}
{{if .Retained}}
<h3>Retention journal (legal dossier of detached credentials)</h3>
<table class="list">
<thead><tr><th>Kind</th><th>Credential</th><th>Reason</th><th>Detached</th></tr></thead>
<tbody>
{{range .Retained}}<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{.Reason}}</td><td>{{.DetachedAt}}</td></tr>{{end}}
</tbody>
</table>
{{end}}
{{if not .Deleted}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/delete" onsubmit="return confirm('Delete this account? Its credentials are journalled and freed, its data anonymised, and its sessions revoked. This cannot be undone.')">
<button type="submit">Delete user</button>
</form>
{{end}}
</section> </section>
<section class="panel"><h2>Friends</h2> <section class="panel"><h2>Friends</h2>
<table class="list"> <table class="list">
@@ -142,6 +165,11 @@
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}} {{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
</section> </section>
{{end}} {{end}}
{{if .VKID}}
<section class="panel"><h2>VK</h2>
<p>VK ID: <code>{{.VKID}}</code> · <a href="https://vk.com/id{{.VKID}}" target="_blank" rel="noopener">open profile</a></p>
</section>
{{end}}
<section class="panel"><h2>Games</h2> <section class="panel"><h2>Games</h2>
<table class="list"> <table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead> <thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
@@ -2,13 +2,15 @@
<h1>Users</h1> <h1>Users</h1>
{{with .Data}} {{with .Data}}
<nav class="subnav"> <nav class="subnav">
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> · <a href="/_gm/users"{{if and (not .Robots) (not .Deleted)}} class="active"{{end}}>People</a> ·
<a href="/_gm/users?kind=deleted"{{if .Deleted}} class="active"{{end}}>Deleted</a> ·
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a> <a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
</nav> </nav>
<form class="form" method="get" action="/_gm/users"> <form class="form" method="get" action="/_gm/users">
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}} {{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}{{if .Deleted}}<input type="hidden" name="kind" value="deleted">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)"> <input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)"> <input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
<input name="email" value="{{.EmailExact}}" placeholder="email (exact)" type="search">
<button type="submit">Filter</button> <button type="submit">Filter</button>
</form> </form>
<table class="list"> <table class="list">
@@ -17,7 +19,7 @@
{{range .Items}} {{range .Items}}
<tr> <tr>
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td> <td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td> <td>{{.DisplayName}}{{if .Deleted}} <span class="pill">deleted</span>{{end}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
<td>{{.Kind}}</td> <td>{{.Kind}}</td>
<td>{{.Language}}</td> <td>{{.Language}}</td>
<td>{{.CreatedAt}}</td> <td>{{.CreatedAt}}</td>
+71 -15
View File
@@ -60,8 +60,10 @@ type UsersView struct {
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&" // be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
// percent-encoded again by the contextual escaper. // percent-encoded again by the contextual escaper.
Robots bool Robots bool
Deleted bool
NameMask string NameMask string
ExternalIDMask string ExternalIDMask string
EmailExact string
FilterQuery template.URL FilterQuery template.URL
} }
@@ -74,6 +76,7 @@ type UserRow struct {
Kind string Kind string
Language string Language string
Guest bool Guest bool
Deleted bool
FlaggedHighRate bool FlaggedHighRate bool
CreatedAt string CreatedAt string
HasMoveStats bool HasMoveStats bool
@@ -150,19 +153,34 @@ type UserDetailView struct {
// MergedInto is the primary account id when this account has been retired by a // MergedInto is the primary account id when this account has been retired by a
// merge, or empty for a live account. // merge, or empty for a live account.
MergedInto string MergedInto string
// The account-deletion dossier. Deleted marks a tombstoned account; DeletedAt and
// DeletedName are its deletion time and retained real name; LastLoginAt/IP are the
// last cold-load stamp (shown for any account); Retained is the credential journal.
Deleted bool
DeletedAt string
DeletedName string
LastLoginAt string
LastLoginIP string
Retained []RetainedRow
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp, // FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
// empty for an unflagged account; the card shows it with the Clear action. // empty for an unflagged account; the card shows it with the Clear action.
FlaggedHighRateAt string FlaggedHighRateAt string
HintBalance int HintBalance int
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the // HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
// server's maxHintGrant), passed through so the policy value lives in one place. // server's maxHintGrant), passed through so the policy value lives in one place.
HintGrantMax int HintGrantMax int
CreatedAt string CreatedAt string
HasStats bool HasStats bool
Stats StatsRow Stats StatsRow
Identities []IdentityRow Identities []IdentityRow
Games []GameRow // HasEmail gates the "Erase email" action; set when the account carries an email identity.
HasEmail bool
Games []GameRow
// TelegramID and VKID are the account's platform external ids (empty when absent).
// TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK
// user id with a link to the VK profile (there is no VK messaging to drive).
TelegramID string TelegramID string
VKID string
ConnectorEnabled bool ConnectorEnabled bool
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think // MoveChart is the pre-rendered inline SVG of the account's per-move-number think
// time (min/mean/max), empty when the account has no timed move. // time (min/mean/max), empty when the account has no timed move.
@@ -230,6 +248,17 @@ type IdentityRow struct {
CreatedAt string CreatedAt string
} }
// RetainedRow is one credential in the account-deletion retention journal (the legal
// dossier of detached credentials): what was detached, when, and why.
type RetainedRow struct {
Kind string
ExternalID string
Reason string
Confirmed bool
LinkedAt string
DetachedAt string
}
// GameRow is one game row in a list. // GameRow is one game row in a list.
type GameRow struct { type GameRow struct {
ID string ID string
@@ -469,15 +498,30 @@ type BannerCampaignRow struct {
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the // BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open. // "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
//
// The colour-override and urgent fields drive the non-default campaign's editor:
// OverrideAllOn/OverrideDarkOn report whether each colour set is active, and the
// six *Bg/*Fg/*Link values seed the native colour inputs — the stored override
// when a set is on, otherwise the neutral theme token so the picker starts from a
// sensible colour and the live preview shows the real fallback.
type BannerDetailView struct { type BannerDetailView struct {
ID string ID string
Name string Name string
Weight int Weight int
IsDefault bool IsDefault bool
Enabled bool Enabled bool
StartsAt string StartsAt string
EndsAt string EndsAt string
Messages []BannerMessageRow Urgent bool
OverrideAllOn bool
AllBg string
AllFg string
AllLink string
OverrideDarkOn bool
DarkBg string
DarkFg string
DarkLink string
Messages []BannerMessageRow
} }
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the // BannerMessageRow is one bilingual message of a campaign. First/Last drive the
@@ -554,5 +598,17 @@ type FeedbackDetailView struct {
ReplyBody string ReplyBody string
RepliedAt string RepliedAt string
CreatedAt string CreatedAt string
Banned bool // Version is the client app build the report was sent from (empty for rows that predate it).
Version string
// The Filed time is shown in three zones so the operator can tell what is certainly known from
// what is merely defaulted. CreatedAt is the authoritative UTC time. CreatedAtBrowser is that
// instant in the client's UTC offset detected at submit (BrowserTZ its "±HH:MM" label), empty
// when the client reported none (an older build). CreatedAtUser is that instant in the sender's
// saved profile zone (UserTZ its label), empty when the account has no zone beyond the UTC
// default — the template then shows "N/A" so the missing datum is explicit.
CreatedAtBrowser string
BrowserTZ string
CreatedAtUser string
UserTZ string
Banned bool
} }
+77 -13
View File
@@ -30,6 +30,15 @@ var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modifi
// window or message body). // window or message body).
var ErrValidation = errors.New("ads: validation") var ErrValidation = errors.New("ads: validation")
// ColorSet is an optional per-campaign colour override for the banner strip:
// background, foreground (text) and link, each a "#rrggbb" hex string. The three
// are set together or the whole set is absent (a nil *ColorSet).
type ColorSet struct {
Bg string
Fg string
Link string
}
// Campaign is one advertising placement order with its messages. // Campaign is one advertising placement order with its messages.
type Campaign struct { type Campaign struct {
ID uuid.UUID // uuid.Nil on create ID uuid.UUID // uuid.Nil on create
@@ -40,6 +49,16 @@ type Campaign struct {
StartsAt *time.Time // nil = open-ended start; always nil for the default StartsAt *time.Time // nil = open-ended start; always nil for the default
EndsAt *time.Time // nil = open-ended end; always nil for the default EndsAt *time.Time // nil = open-ended end; always nil for the default
Messages []Message Messages []Message
// OverrideAll paints the strip on every theme; OverrideDark, when set, further
// overrides the dark theme (the client resolves dark ← dark ?? all ?? token,
// light ← all ?? token). Both are nil for the default campaign and for a
// campaign that keeps the neutral theme tokens. Non-default only.
OverrideAll *ColorSet
OverrideDark *ColorSet
// Urgent forces the banner on every viewer (bypassing eligibility) and, while
// any urgent campaign is active, suppresses every non-urgent campaign and the
// default remainder. Non-default only; always false for the default campaign.
Urgent bool
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -70,10 +89,15 @@ type Timings struct {
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client: // ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
// its GCD-reduced show weight and its messages, already resolved to the viewer's // its GCD-reduced show weight and its messages, already resolved to the viewer's
// language and in display (round-robin) order. // language and in display (round-robin) order, plus the optional colour overrides
// the client applies to the strip (nil = the neutral theme tokens). Urgency is not
// carried here: it is resolved server-side into the set's membership and the
// eligibility bypass, so the client only ever renders what it is sent.
type ActiveCampaign struct { type ActiveCampaign struct {
Weight int Weight int
Messages []string Messages []string
OverrideAll *ColorSet
OverrideDark *ColorSet
} }
// Eligible reports whether an account should be shown the advertising banner: a // Eligible reports whether an account should be shown the advertising banner: a
@@ -85,14 +109,20 @@ func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
} }
// computeActiveSet builds the resolved rotation feed from the enabled campaigns // computeActiveSet builds the resolved rotation feed from the enabled campaigns
// at time now, in language lang. Campaigns outside their validity window, and // at time now, in language lang, and reports whether the feed is an urgent one.
// campaigns with no messages, are dropped. The default campaign's effective // Campaigns outside their validity window, and campaigns with no messages, are
// weight is the remainder up to 100% — max(0, 100 - sum of active timed // dropped.
// weights) — so it fills unsold inventory and is dropped entirely when timed //
// campaigns already reach 100%. Weights are then reduced by their GCD so the // While any active campaign is urgent, the feed is the urgent campaigns alone —
// fair rotation cycle stays short. The input is expected to be the enabled // every non-urgent timed campaign and the default remainder are suppressed for
// campaigns (ActiveCampaigns); disabled ones must already be excluded. // the duration (and the caller shows the feed to every viewer, bypassing
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign { // eligibility). Otherwise the default campaign's effective weight is the
// remainder up to 100% — max(0, 100 - sum of active timed weights) — so it fills
// unsold inventory and is dropped entirely when timed campaigns already reach
// 100%. Weights are then reduced by their GCD so the fair rotation cycle stays
// short. The input is expected to be the enabled campaigns (ActiveCampaigns);
// disabled ones must already be excluded.
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]ActiveCampaign, bool) {
var timed []Campaign var timed []Campaign
var def *Campaign var def *Campaign
for i := range campaigns { for i := range campaigns {
@@ -108,20 +138,54 @@ func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []Active
timed = append(timed, c) timed = append(timed, c)
} }
} }
if urgent := filterUrgent(timed); len(urgent) > 0 {
out := make([]ActiveCampaign, 0, len(urgent))
for _, c := range urgent {
out = append(out, activeFrom(c, lang))
}
reduceByGCD(out)
return out, true
}
sumTimed := 0 sumTimed := 0
for _, c := range timed { for _, c := range timed {
sumTimed += c.Weight sumTimed += c.Weight
} }
out := make([]ActiveCampaign, 0, len(timed)+1) out := make([]ActiveCampaign, 0, len(timed)+1)
for _, c := range timed { for _, c := range timed {
out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)}) out = append(out, activeFrom(c, lang))
} }
if def != nil { if def != nil {
if dw := 100 - sumTimed; dw > 0 { if dw := 100 - sumTimed; dw > 0 {
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)}) a := activeFrom(*def, lang)
a.Weight = dw // the default's stored weight is nominal; it fills the remainder
out = append(out, a)
} }
} }
reduceByGCD(out) reduceByGCD(out)
return out, false
}
// activeFrom projects a campaign to its rotation-feed entry: its show weight, its
// language-resolved messages and its colour overrides (carried through by
// reference, they are read-only).
func activeFrom(c Campaign, lang string) ActiveCampaign {
return ActiveCampaign{
Weight: c.Weight,
Messages: resolveBodies(c.Messages, lang),
OverrideAll: c.OverrideAll,
OverrideDark: c.OverrideDark,
}
}
// filterUrgent returns the urgent campaigns among cs, preserving order. It is the
// preempt selector: a non-empty result makes the whole feed urgent.
func filterUrgent(cs []Campaign) []Campaign {
var out []Campaign
for _, c := range cs {
if c.Urgent {
out = append(out, c)
}
}
return out return out
} }
+63 -7
View File
@@ -46,12 +46,19 @@ func TestComputeActiveSet(t *testing.T) {
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign { timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs} return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
} }
urgent := func(name string, weight int, msgs []Message) Campaign {
c := timed(name, weight, nil, nil, msgs)
c.Urgent = true
return c
}
red := &ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"}
tests := []struct { tests := []struct {
name string name string
campaigns []Campaign campaigns []Campaign
lang string lang string
want []ActiveCampaign want []ActiveCampaign
wantUrgent bool
}{ }{
{ {
name: "default only reduces to weight 1", name: "default only reduces to weight 1",
@@ -152,22 +159,71 @@ func TestComputeActiveSet(t *testing.T) {
lang: "en", lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}}, want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
}, },
{
name: "urgent preempts default and normal timed",
campaigns: []Campaign{
def(msg("house")),
timed("promo", 40, nil, nil, msg("promo")),
urgent("alert", 50, msg("alert")),
},
lang: "en",
// only the urgent campaign survives; a lone weight reduces to 1.
want: []ActiveCampaign{{Weight: 1, Messages: []string{"alert-en"}}},
wantUrgent: true,
},
{
name: "multiple urgent share the feed by gcd",
campaigns: []Campaign{
def(msg("house")),
urgent("a", 60, msg("a")),
urgent("b", 40, msg("b")),
},
lang: "en",
// default and any non-urgent dropped; gcd(60,40)=20 -> 3 and 2.
want: []ActiveCampaign{
{Weight: 3, Messages: []string{"a-en"}},
{Weight: 2, Messages: []string{"b-en"}},
},
wantUrgent: true,
},
{
name: "out-of-window urgent does not preempt",
campaigns: []Campaign{
def(msg("house")),
func() Campaign { c := urgent("future", 50, msg("future")); c.StartsAt = &future; return c }(),
},
lang: "en",
// the urgent campaign is not yet live, so the normal default feed stands.
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "colour overrides ride the active campaign",
campaigns: []Campaign{
def(msg("house")),
func() Campaign { c := timed("promo", 100, nil, nil, msg("promo")); c.OverrideAll = red; return c }(),
},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}, OverrideAll: red}},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := computeActiveSet(tt.campaigns, now, tt.lang) got, gotUrgent := computeActiveSet(tt.campaigns, now, tt.lang)
if !reflect.DeepEqual(got, tt.want) { if !reflect.DeepEqual(got, tt.want) {
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want) t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
} }
if gotUrgent != tt.wantUrgent {
t.Errorf("computeActiveSet() urgent = %v, want %v", gotUrgent, tt.wantUrgent)
}
}) })
} }
} }
func TestComputeActiveSetEmpty(t *testing.T) { func TestComputeActiveSetEmpty(t *testing.T) {
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking. // No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 { if got, urgent := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 || urgent {
t.Errorf("computeActiveSet(nil) = %#v, want empty", got) t.Errorf("computeActiveSet(nil) = %#v urgent=%v, want empty non-urgent", got, urgent)
} }
} }
+62 -5
View File
@@ -3,6 +3,7 @@ package ads
import ( import (
"context" "context"
"fmt" "fmt"
"regexp"
"strings" "strings"
"time" "time"
@@ -40,17 +41,20 @@ func NewService(store *Store) *Service { return &Service{store: store} }
// ActiveSet returns the resolved rotation feed for a viewer in language lang // ActiveSet returns the resolved rotation feed for a viewer in language lang
// (en/ru) together with the global display timings: the currently-active // (en/ru) together with the global display timings: the currently-active
// campaigns, each with its GCD-reduced show weight and its messages resolved to // campaigns, each with its GCD-reduced show weight and its messages resolved to
// lang, ready for the client's weighted round-robin. // lang, ready for the client's weighted round-robin. The bool result reports
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) { // whether the feed is urgent — an urgent feed is shown to every viewer
// regardless of eligibility (the caller skips the eligibility gate for it).
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, bool, error) {
campaigns, err := s.store.ActiveCampaigns(ctx) campaigns, err := s.store.ActiveCampaigns(ctx)
if err != nil { if err != nil {
return nil, Timings{}, err return nil, Timings{}, false, err
} }
timings, err := s.store.Settings(ctx) timings, err := s.store.Settings(ctx)
if err != nil { if err != nil {
return nil, Timings{}, err return nil, Timings{}, false, err
} }
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil set, urgent := computeActiveSet(campaigns, time.Now().UTC(), lang)
return set, timings, urgent, nil
} }
// ListCampaigns returns every campaign with its messages, for the admin console. // ListCampaigns returns every campaign with its messages, for the admin console.
@@ -76,8 +80,13 @@ func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, er
if err := validWindow(c.StartsAt, c.EndsAt); err != nil { if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return uuid.Nil, err return uuid.Nil, err
} }
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
if err != nil {
return uuid.Nil, err
}
return s.store.CreateCampaign(ctx, Campaign{ return s.store.CreateCampaign(ctx, Campaign{
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt, Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
OverrideAll: all, OverrideDark: dark, Urgent: c.Urgent,
}) })
} }
@@ -99,6 +108,7 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
upd.Enabled = true upd.Enabled = true
upd.StartsAt = nil upd.StartsAt = nil
upd.EndsAt = nil upd.EndsAt = nil
// The default (house) campaign stays plain: no colour overrides, never urgent.
} else { } else {
if err := validWeight(c.Weight); err != nil { if err := validWeight(c.Weight); err != nil {
return err return err
@@ -106,10 +116,17 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
if err := validWindow(c.StartsAt, c.EndsAt); err != nil { if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return err return err
} }
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
if err != nil {
return err
}
upd.Weight = c.Weight upd.Weight = c.Weight
upd.Enabled = c.Enabled upd.Enabled = c.Enabled
upd.StartsAt = c.StartsAt upd.StartsAt = c.StartsAt
upd.EndsAt = c.EndsAt upd.EndsAt = c.EndsAt
upd.OverrideAll = all
upd.OverrideDark = dark
upd.Urgent = c.Urgent
} }
return s.store.UpdateCampaign(ctx, upd) return s.store.UpdateCampaign(ctx, upd)
} }
@@ -254,6 +271,46 @@ func validWindow(starts, ends *time.Time) error {
return nil return nil
} }
// hexColor matches a "#rrggbb" colour — the format the console's native colour
// input emits and the wire carries.
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// validOverrides validates the two optional colour sets together and returns
// their normalised copies (nil when a set is absent), so a caller can store them
// directly.
func validOverrides(all, dark *ColorSet) (*ColorSet, *ColorSet, error) {
va, err := validColorSet(all)
if err != nil {
return nil, nil, err
}
vd, err := validColorSet(dark)
if err != nil {
return nil, nil, err
}
return va, vd, nil
}
// validColorSet validates one optional colour override: a nil set passes (no
// override); otherwise all three colours must be present and "#rrggbb". It
// returns a trimmed, lower-cased copy.
func validColorSet(cs *ColorSet) (*ColorSet, error) {
if cs == nil {
return nil, nil
}
bg := strings.TrimSpace(cs.Bg)
fg := strings.TrimSpace(cs.Fg)
link := strings.TrimSpace(cs.Link)
if bg == "" || fg == "" || link == "" {
return nil, fmt.Errorf("%w: a colour override needs all of background, text and link", ErrValidation)
}
for _, h := range []string{bg, fg, link} {
if !hexColor.MatchString(h) {
return nil, fmt.Errorf("%w: colours must be #rrggbb hex", ErrValidation)
}
}
return &ColorSet{Bg: strings.ToLower(bg), Fg: strings.ToLower(fg), Link: strings.ToLower(link)}, nil
}
// validBodies trims and bounds both mandatory language bodies of a message. // validBodies trims and bounds both mandatory language bodies of a message.
func validBodies(bodyEn, bodyRu string) (string, string, error) { func validBodies(bodyEn, bodyRu string) (string, string, error) {
en := strings.TrimSpace(bodyEn) en := strings.TrimSpace(bodyEn)
+49 -11
View File
@@ -90,15 +90,23 @@ func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) { func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
id := uuid.New() id := uuid.New()
now := time.Now().UTC() now := time.Now().UTC()
abg, afg, alink := colorCols(c.OverrideAll)
dbg, dfg, dlink := colorCols(c.OverrideDark)
stmt := table.AdCampaigns.INSERT( stmt := table.AdCampaigns.INSERT(
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled, table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
table.AdCampaigns.Urgent,
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt, table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
).VALUES( ).VALUES(
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
postgres.Bool(false), postgres.Bool(c.Enabled), postgres.Bool(false), postgres.Bool(c.Enabled),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
abg, afg, alink,
dbg, dfg, dlink,
postgres.Bool(c.Urgent),
postgres.TimestampzT(now), postgres.TimestampzT(now), postgres.TimestampzT(now), postgres.TimestampzT(now),
) )
if _, err := stmt.ExecContext(ctx, s.db); err != nil { if _, err := stmt.ExecContext(ctx, s.db); err != nil {
@@ -111,12 +119,20 @@ func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, erro
// window. The default flag is never touched here. Returns ErrNotFound when no // window. The default flag is never touched here. Returns ErrNotFound when no
// campaign matches. // campaign matches.
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error { func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
abg, afg, alink := colorCols(c.OverrideAll)
dbg, dfg, dlink := colorCols(c.OverrideDark)
stmt := table.AdCampaigns.UPDATE( stmt := table.AdCampaigns.UPDATE(
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled, table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt, table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
table.AdCampaigns.Urgent, table.AdCampaigns.UpdatedAt,
).SET( ).SET(
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled), postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()), tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
abg, afg, alink,
dbg, dfg, dlink,
postgres.Bool(c.Urgent), postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID))) ).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
return execOne(ctx, s.db, stmt, "update campaign") return execOne(ctx, s.db, stmt, "update campaign")
} }
@@ -257,18 +273,40 @@ func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, wh
func modelToCampaign(r model.AdCampaigns) Campaign { func modelToCampaign(r model.AdCampaigns) Campaign {
return Campaign{ return Campaign{
ID: r.CampaignID, ID: r.CampaignID,
Name: r.Name, Name: r.Name,
Weight: int(r.Weight), Weight: int(r.Weight),
IsDefault: r.IsDefault, IsDefault: r.IsDefault,
Enabled: r.Enabled, Enabled: r.Enabled,
StartsAt: r.StartsAt, StartsAt: r.StartsAt,
EndsAt: r.EndsAt, EndsAt: r.EndsAt,
CreatedAt: r.CreatedAt, OverrideAll: colorSetFrom(r.OverrideBg, r.OverrideFg, r.OverrideLink),
UpdatedAt: r.UpdatedAt, OverrideDark: colorSetFrom(r.OverrideBgDark, r.OverrideFgDark, r.OverrideLinkDark),
Urgent: r.Urgent,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
} }
} }
// colorSetFrom rebuilds an optional ColorSet from three nullable colour columns.
// The all-or-nothing CHECK keeps the trio consistent, so a nil in any one means
// the set is absent.
func colorSetFrom(bg, fg, link *string) *ColorSet {
if bg == nil || fg == nil || link == nil {
return nil
}
return &ColorSet{Bg: *bg, Fg: *fg, Link: *link}
}
// colorCols renders a *ColorSet as its three column value-expressions in
// bg, fg, link order — NULL for each when the set is absent.
func colorCols(cs *ColorSet) (bg, fg, link postgres.Expression) {
if cs == nil {
return postgres.NULL, postgres.NULL, postgres.NULL
}
return postgres.String(cs.Bg), postgres.String(cs.Fg), postgres.String(cs.Link)
}
func modelToMessage(r model.AdMessages) Message { func modelToMessage(r model.AdMessages) Message {
return Message{ return Message{
ID: r.MessageID, ID: r.MessageID,
+32 -5
View File
@@ -4,6 +4,7 @@ package config
import ( import (
"fmt" "fmt"
"net/url"
"os" "os"
"strconv" "strconv"
"time" "time"
@@ -42,6 +43,12 @@ type Config struct {
// SMTP configures the email relay used for confirm-codes. An empty Host // SMTP configures the email relay used for confirm-codes. An empty Host
// selects the development log mailer (the code is logged, not sent). // selects the development log mailer (the code is logged, not sent).
SMTP account.SMTPConfig SMTP account.SMTPConfig
// PublicBaseURL is the canonical public origin (scheme + host, e.g.
// https://erudit-game.ru) used to build absolute links in outgoing email — the
// confirm deeplink and the footer landing link. It is deliberately not derived
// from a request Host header, which would let an attacker inject a phishing link
// into the email. Required whenever an SMTP relay is configured.
PublicBaseURL string
// ConnectorAddr is the gRPC address of the Telegram platform connector // ConnectorAddr is the gRPC address of the Telegram platform connector
// side-service, used by the admin console to send operator broadcasts. Empty // side-service, used by the admin console to send operator broadcasts. Empty
// disables broadcasts (the admin broadcast actions report "not configured"). // disables broadcasts (the admin broadcast actions report "not configured").
@@ -51,6 +58,12 @@ type Config struct {
// GuestRetention is the account age past which an unused guest (no game seat) // GuestRetention is the account age past which an unused guest (no game seat)
// is eligible for deletion by the reaper. // is eligible for deletion by the reaper.
GuestRetention time.Duration GuestRetention time.Duration
// ExportSignKey signs the finished-game export download URLs. Empty leaves
// the export-URL endpoints disabled (503 on mint, 404 on download).
ExportSignKey string
// RendererURL is the base URL of the internal image-render sidecar (e.g.
// http://renderer:8090). Empty disables the PNG export artifact.
RendererURL string
} }
// Defaults applied when the corresponding environment variable is unset. // Defaults applied when the corresponding environment variable is unset.
@@ -130,11 +143,14 @@ func Load() (Config, error) {
} }
smtp := account.SMTPConfig{ smtp := account.SMTPConfig{
Host: os.Getenv("BACKEND_SMTP_HOST"), Host: os.Getenv("BACKEND_SMTP_HOST"),
Port: envOr("BACKEND_SMTP_PORT", "587"), Port: envOr("BACKEND_SMTP_PORT", "587"),
Username: os.Getenv("BACKEND_SMTP_USERNAME"), Username: os.Getenv("BACKEND_SMTP_USERNAME"),
Password: os.Getenv("BACKEND_SMTP_PASSWORD"), Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"), From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
TLS: os.Getenv("BACKEND_SMTP_TLS"),
AdminFrom: os.Getenv("BACKEND_SMTP_ADMIN_FROM"),
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
} }
c := Config{ c := Config{
@@ -148,9 +164,12 @@ func Load() (Config, error) {
Robot: rb, Robot: rb,
RateWatch: rw, RateWatch: rw,
SMTP: smtp, SMTP: smtp,
PublicBaseURL: os.Getenv("BACKEND_PUBLIC_BASE_URL"),
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"), ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
GuestReapInterval: guestReapInterval, GuestReapInterval: guestReapInterval,
GuestRetention: guestRetention, GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
} }
if err := c.validate(); err != nil { if err := c.validate(); err != nil {
return Config{}, err return Config{}, err
@@ -195,6 +214,14 @@ func (c Config) validate() error {
if c.GuestRetention <= 0 { if c.GuestRetention <= 0 {
return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive") return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
} }
if c.SMTP.Host != "" {
if c.PublicBaseURL == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when BACKEND_SMTP_HOST is configured")
}
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
}
}
return nil return nil
} }
+1 -1
View File
@@ -21,7 +21,7 @@ const (
// ActionResign abandons the game. // ActionResign abandons the game.
ActionResign ActionResign
// ActionTimeout is the auto-resignation a missed turn becomes; recorded by // ActionTimeout is the auto-resignation a missed turn becomes; recorded by
// the game domain in a later stage, never produced by the engine itself. // the game domain, never produced by the engine itself.
ActionTimeout ActionTimeout
) )
+1 -1
View File
@@ -10,7 +10,7 @@
// characters (see decode.go and docs/ARCHITECTURE.md §9.1), so archived games // characters (see decode.go and docs/ARCHITECTURE.md §9.1), so archived games
// replay independently of any dictionary. Second, the engine owns rules and // replay independently of any dictionary. Second, the engine owns rules and
// scoring only: turn scheduling, the 24-hour timeout, persistence and transport // scoring only: turn scheduling, the 24-hour timeout, persistence and transport
// belong to the game domain in a later stage. // belong to the game domain.
package engine package engine
import ( import (
+30 -4
View File
@@ -21,17 +21,19 @@ var dictFiles = map[Variant]string{
VariantErudit: "ru_erudit.dawg", VariantErudit: "ru_erudit.dawg",
} }
// entry is one resident dictionary: the loaded finder and the solver built over // entry is one resident dictionary: the loaded finder, the solver built over it
// it. The finder is retained so Close can release it. // and the file it was loaded from. The finder is retained so Close can release
// it; path is retained so the raw bytes can be re-read for the client download.
type entry struct { type entry struct {
finder dawg.Finder finder dawg.Finder
solver *scrabble.Solver solver *scrabble.Solver
path string
} }
// Registry holds the dictionaries resident in memory, addressed by variant and // Registry holds the dictionaries resident in memory, addressed by variant and
// dictionary version, and the solvers built over them. Several versions of a // dictionary version, and the solvers built over them. Several versions of a
// variant may be resident at once; a game pins the version it started on. The // variant may be resident at once; a game pins the version it started on. The
// admin reload flow (a later stage) registers a new version through Load. // admin reload flow registers a new version through Load.
// Registry is safe for concurrent use. // Registry is safe for concurrent use.
type Registry struct { type Registry struct {
mu sync.RWMutex mu sync.RWMutex
@@ -130,7 +132,7 @@ func (r *Registry) Load(v Variant, version, dir string) error {
if old, ok := r.entries[v][version]; ok { if old, ok := r.entries[v][version]; ok {
_ = old.finder.Close() _ = old.finder.Close()
} }
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder)} r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder), path: path}
r.latest[v] = version r.latest[v] = version
return nil return nil
} }
@@ -202,6 +204,30 @@ func (r *Registry) Versions(v Variant) []string {
return versions return versions
} }
// DictBytes returns the raw serialized DAWG for the (variant, version) pair,
// re-read from the file it was loaded from — the same immutable bytes the solver
// holds. It backs the client-side dictionary download for the local move
// preview. It returns ErrUnknownVariant or ErrUnknownVersion when that dictionary
// is not resident, and wraps any read error. The file is read outside the lock.
func (r *Registry) DictBytes(v Variant, version string) ([]byte, error) {
r.mu.RLock()
versions, ok := r.entries[v]
if !ok {
r.mu.RUnlock()
return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
}
e, ok := versions[version]
r.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
}
data, err := os.ReadFile(e.path)
if err != nil {
return nil, fmt.Errorf("engine: read %s/%s dictionary bytes from %s: %w", v, version, e.path, err)
}
return data, nil
}
// Lookup reports whether word is present in the (variant, version) dictionary, // Lookup reports whether word is present in the (variant, version) dictionary,
// backing the unlimited word-check tool. It returns ErrUnknownVariant or // backing the unlimited word-check tool. It returns ErrUnknownVariant or
// ErrUnknownVersion when that dictionary is not resident, and an error when word // ErrUnknownVersion when that dictionary is not resident, and an error when word
+10 -4
View File
@@ -72,7 +72,7 @@ func (svc *Service) SetNotifier(p notify.Publisher) {
// validates the body (non-empty, within the rune limit) and the optional // validates the body (non-empty, within the rune limit) and the optional
// attachment (size and extension allow-list). senderIP is the gateway-forwarded // attachment (size and extension allow-list). senderIP is the gateway-forwarded
// client IP (validated); channel is the submitting platform. // client IP (validated); channel is the submitting platform.
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error { func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, version, browserTZ, senderIP string) error {
acc, err := svc.accounts.GetByID(ctx, accountID) acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil { if err != nil {
return err return err
@@ -112,9 +112,10 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string
attachmentName = "" // a name without bytes carries no attachment attachmentName = "" // a name without bytes carries no attachment
} }
ch := normalizeChannel(channel) ch := normalizeChannel(channel)
// Snapshot the sender's interface language at submit time (acc is already loaded // Snapshot the sender's interface language, the client app version and the client's
// for the guest check) so the operator later sees the state as it was. // detected UTC offset at submit time (acc is already loaded for the guest check) so the
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP)) // operator later sees the state as it was.
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, version, browserTZ, parseIP(senderIP))
return err return err
} }
@@ -194,6 +195,11 @@ func (svc *Service) CountUnread(ctx context.Context) (int, error) {
return svc.store.CountUnread(ctx) return svc.store.CountUnread(ctx)
} }
// CountSince counts feedback created after since, for the operator alert worker.
func (svc *Service) CountSince(ctx context.Context, since time.Time) (int, error) {
return svc.store.CountSince(ctx, since)
}
// Attachment returns a message's file name and bytes, reporting false when absent. // Attachment returns a message's file name and bytes, reporting false when absent.
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) { func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
return svc.store.Attachment(ctx, id) return svc.store.Attachment(ctx, id)
+30 -10
View File
@@ -34,10 +34,10 @@ func NewStore(db *sql.DB) *Store {
// Insert stores one feedback message from accountID and returns its id. attachment // Insert stores one feedback message from accountID and returns its id. attachment
// is the raw file bytes (nil for none); attachmentName, ip and a non-default // is the raw file bytes (nil for none); attachmentName, ip and a non-default
// channel are stored as given. lang (the sender's interface language) is a snapshot // channel are stored as given. lang (interface language), version (client app build) and
// taken now, so the operator later sees the state at submit time. created_at defaults // browserTZ (the client's detected "±HH:MM" UTC offset) are snapshots taken now, so the operator
// to now() in the database. // later sees the state at submit time. created_at defaults to now() in the database.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang string, ip *string) (uuid.UUID, error) { func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, version, browserTZ string, ip *string) (uuid.UUID, error) {
id, err := uuid.NewV7() id, err := uuid.NewV7()
if err != nil { if err != nil {
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err) return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
@@ -48,9 +48,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at
} }
if _, err := s.db.ExecContext(ctx, if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.feedback_messages `INSERT INTO backend.feedback_messages
(message_id, account_id, body, attachment, attachment_name, channel, lang, sender_ip) (message_id, account_id, body, attachment, attachment_name, channel, lang, app_version, browser_tz, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), ip); err != nil { id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(version), nullStr(browserTZ), ip); err != nil {
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err) return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
} }
return id, nil return id, nil
@@ -228,7 +228,15 @@ type AdminMessage struct {
Body string Body string
Channel string Channel string
// Lang is the sender's interface language, snapshotted at submit time. // Lang is the sender's interface language, snapshotted at submit time.
Lang string Lang string
// Version is the client app build the report was sent from, snapshotted at submit time.
Version string
// BrowserTZ is the client's detected "±HH:MM" UTC offset at submit time, snapshotted so the
// filed time can be shown in the sender's browser-local zone even before they save a profile.
BrowserTZ string
// TimeZone is the sender account's stored zone ("±HH:MM" offset, IANA name, or ""), for
// rendering CreatedAt in the sender's own configured time alongside UTC.
TimeZone string
SenderIP string SenderIP string
HasAttachment bool HasAttachment bool
AttachmentName string AttachmentName string
@@ -343,7 +351,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
var m AdminMessage var m AdminMessage
var repliedAt sql.NullTime var repliedAt sql.NullTime
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel, q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
COALESCE(m.lang, ''), COALESCE(m.lang, ''), COALESCE(m.app_version, ''), COALESCE(m.browser_tz, ''), a.time_zone,
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''), COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL), (m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
COALESCE(m.reply_body, ''), m.replied_at, m.created_at COALESCE(m.reply_body, ''), m.replied_at, m.created_at
@@ -352,7 +360,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
WHERE m.message_id = $1` WHERE m.message_id = $1`
err := s.db.QueryRowContext(ctx, q, id).Scan( err := s.db.QueryRowContext(ctx, q, id).Scan(
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel, &m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
&m.Lang, &m.Lang, &m.Version, &m.BrowserTZ, &m.TimeZone,
&m.SenderIP, &m.HasAttachment, &m.AttachmentName, &m.SenderIP, &m.HasAttachment, &m.AttachmentName,
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt) &m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -378,3 +386,15 @@ func (s *Store) CountUnread(ctx context.Context) (int, error) {
} }
return n, nil return n, nil
} }
// CountSince counts feedback messages created strictly after since — the operator alert
// worker's "new since the last check" signal.
func (s *Store) CountSince(ctx context.Context, since time.Time) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM backend.feedback_messages WHERE created_at > $1`, since,
).Scan(&n); err != nil {
return 0, fmt.Errorf("feedback: count since: %w", err)
}
return n, nil
}
+1 -1
View File
@@ -16,5 +16,5 @@
// word-check tool with complaint capture, per-player game state, history and GCG // word-check tool with complaint capture, per-player game state, history and GCG
// export, and the per-game turn-timeout sweeper that auto-resigns an overdue // export, and the per-game turn-timeout sweeper that auto-resigns an overdue
// player (honouring their daily away window). The HTTP surface that fronts these // player (honouring their daily away window). The HTTP surface that fronts these
// operations is added with the gateway in a later stage. // operations is exposed to the gateway.
package game package game
+85 -12
View File
@@ -54,8 +54,14 @@ type Service struct {
// have committed a move (a nudge answered by moving stops counting as unread). It is // have committed a move (a nudge answered by moving stops counting as unread). It is
// best-effort and kept as a func so the game package never imports the social package. // best-effort and kept as a func so the game package never imports the social package.
clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error
metrics *gameMetrics // expireNudges, when set, marks every pending nudge in a game read once the game
log *zap.Logger // finishes (the nudge badge is stale on a completed game). Unlike clearNudges it is
// keyed by game alone — it clears all seats' nudges, not one mover's — and runs on
// every completion path through commit. Best-effort; a func so the game package never
// imports the social package.
expireNudges func(ctx context.Context, gameID uuid.UUID) error
metrics *gameMetrics
log *zap.Logger
} }
// NewService constructs a Service. store and accounts wrap the same pool; // NewService constructs a Service. store and accounts wrap the same pool;
@@ -107,6 +113,15 @@ func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, account
svc.clearNudges = fn svc.clearNudges = fn
} }
// SetNudgeExpirer installs the hook that marks every pending nudge in a game read once the
// game finishes, on any completion path (a closing move, a resignation, a turn-timeout or a
// forfeit). It must be called during startup wiring; the default (nil) leaves a finished
// game's nudges to expire only when a recipient opens the move history or chat. The social
// package wires its ExpireNudges here. Chat messages are deliberately left unread.
func (svc *Service) SetNudgeExpirer(fn func(ctx context.Context, gameID uuid.UUID) error) {
svc.expireNudges = fn
}
// SetFirstMoveEntropy overrides the entropy source for the first-move draw // SetFirstMoveEntropy overrides the entropy source for the first-move draw
// (docs/ARCHITECTURE.md §6). It must be called during wiring or test setup before any // (docs/ARCHITECTURE.md §6). It must be called during wiring or test setup before any
// game is created; the production default is crypto/rand and is never overridden. // game is created; the production default is crypto/rand and is never overridden.
@@ -699,6 +714,16 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
} }
if c.finished { if c.finished {
svc.cache.remove(gameID) svc.cache.remove(gameID)
// A finished game's nudges are stale, so clear them all here — every completion path
// funnels through commit (a closing move, a resignation, a forfeit or a turn-timeout),
// and only the move path also clears the mover's nudge on its own. Best-effort like
// clearNudges: the finish has committed, so a cleanup failure is logged, not surfaced.
// ExpireNudges leaves chat messages unread.
if svc.expireNudges != nil {
if err := svc.expireNudges(ctx, gameID); err != nil {
svc.log.Warn("expire nudges on game finish", zap.Error(err))
}
}
} }
post, err := svc.store.GetGame(ctx, gameID) post, err := svc.store.GetGame(ctx, gameID)
if err != nil { if err != nil {
@@ -983,6 +1008,12 @@ func (svc *Service) CountComplaints(ctx context.Context, status string) (int, er
return svc.store.CountComplaints(ctx, status) return svc.store.CountComplaints(ctx, status)
} }
// CountComplaintsSince counts word complaints filed after since, for the operator alert
// worker.
func (svc *Service) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) {
return svc.store.CountComplaintsSince(ctx, since)
}
// ResolveComplaint closes a complaint with an operator disposition (reject / // ResolveComplaint closes a complaint with an operator disposition (reject /
// accept_add / accept_remove) and an optional note. An accepted complaint then // accept_add / accept_remove) and an optional note. An accepted complaint then
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the // appears in DictionaryChanges until a rebuilt dictionary is loaded and the
@@ -1338,30 +1369,53 @@ func (svc *Service) SetupDraws(ctx context.Context, gameID uuid.UUID) ([]SetupDr
return svc.store.SetupDraws(ctx, gameID) return svc.store.SetupDraws(ctx, gameID)
} }
// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It // ExportView returns a finished game with its journal and per-seat display names —
// is allowed only on a finished game: exporting an in-progress game would leak the // the material every export artifact (the GCG text, the PNG render payload) is built
// full move journal mid-play, so an active game yields ErrGameActive. // from. It is allowed only on a finished game: exporting an in-progress game would
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) { // leak the full move journal mid-play, so an active game yields ErrGameActive. In an
// honest-AI game the robot seat is labelled "AI", not its pool name.
func (svc *Service) ExportView(ctx context.Context, gameID uuid.UUID) (Game, []HistoryMove, []string, error) {
g, err := svc.store.GetGame(ctx, gameID) g, err := svc.store.GetGame(ctx, gameID)
if err != nil { if err != nil {
return "", err return Game{}, nil, nil, err
} }
if g.Status != StatusFinished { if g.Status != StatusFinished {
return "", ErrGameActive return Game{}, nil, nil, ErrGameActive
} }
moves, err := svc.store.GetJournal(ctx, gameID) moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil { if err != nil {
return "", err return Game{}, nil, nil, err
} }
names := svc.seatNames(ctx, g) names := svc.seatNames(ctx, g)
if g.VsAI { if g.VsAI {
// Label the robot seat "AI" in an honest-AI game's export, not its pool name.
for _, s := range g.Seats { for _, s := range g.Seats {
if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot { if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot {
names[s.Seat] = aiPlayerName names[s.Seat] = aiPlayerName
} }
} }
} }
return g, moves, names, nil
}
// EnsureExportable reports whether a game may be exported (it exists and is
// finished) without loading the journal — the export-URL mint check.
func (svc *Service) EnsureExportable(ctx context.Context, gameID uuid.UUID) error {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return err
}
if g.Status != StatusFinished {
return ErrGameActive
}
return nil
}
// ExportGCG renders a game as GCG text from the journal alone (no dictionary).
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
g, moves, names, err := svc.ExportView(ctx, gameID)
if err != nil {
return "", err
}
return writeGCG(g, names, moves), nil return writeGCG(g, names, moves), nil
} }
@@ -1440,13 +1494,24 @@ func (svc *Service) voidGame(ctx context.Context, pre Game, g *engine.Game) erro
if err != nil { if err != nil {
return err return err
} }
return svc.store.VoidGame(ctx, voidCommit{ if err := svc.store.VoidGame(ctx, voidCommit{
gameID: pre.ID, gameID: pre.ID,
endReason: g.Reason().String(), endReason: g.Reason().String(),
scores: scores, scores: scores,
now: svc.clock(), now: svc.clock(),
stats: buildStats(g, statSeats), stats: buildStats(g, statSeats),
}) }); err != nil {
return err
}
// A voided game is finished (as a draw) but bypasses commit, so clear its now-stale nudges
// here too. Best-effort, like the commit path: the void has persisted, so a cleanup failure
// is logged, not surfaced.
if svc.expireNudges != nil {
if err := svc.expireNudges(ctx, pre.ID); err != nil {
svc.log.Warn("expire nudges on voided game", zap.Error(err))
}
}
return nil
} }
// replayMove re-applies one journalled move to g through the decoded engine API. // replayMove re-applies one journalled move to g through the decoded engine API.
@@ -1613,6 +1678,14 @@ func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bo
return present, nil return present, nil
} }
// DictBytes returns the raw serialized dictionary for the (variant, version) pair
// from the registry, backing the client-side dictionary download used by the
// local move preview. It surfaces engine.ErrUnknownVariant /
// engine.ErrUnknownVersion when that dictionary is not resident.
func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, error) {
return svc.registry.DictBytes(variant, version)
}
// hintsRemaining is a player's remaining hint budget: the unspent per-game // hintsRemaining is a player's remaining hint budget: the unspent per-game
// allowance plus the profile wallet. // allowance plus the profile wallet.
func hintsRemaining(allowance, used, wallet int) int { func hintsRemaining(allowance, used, wallet int) int {
+13
View File
@@ -1040,6 +1040,19 @@ func (s *Store) CountComplaints(ctx context.Context, status string) (int, error)
return int(dest.Count), nil return int(dest.Count), nil
} }
// CountComplaintsSince counts word complaints filed strictly after since — the operator
// alert worker's "new since the last check" signal.
func (s *Store) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) {
stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")).
FROM(table.Complaints).
WHERE(table.Complaints.CreatedAt.GT(postgres.TimestampzT(since)))
var dest struct{ Count int64 }
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("game: count complaints since: %w", err)
}
return int(dest.Count), nil
}
// ActiveGames returns the turn clocks of every in-progress game; the sweeper // ActiveGames returns the turn clocks of every in-progress game; the sweeper
// filters them against the per-move deadline and the player's away window. // filters them against the per-move deadline and the player's away window.
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) { func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
+1 -1
View File
@@ -105,7 +105,7 @@ const MaxActiveQuickGames = 10
const aiPlayerName = "AI" const aiPlayerName = "AI"
// CreateParams describes a new game. Seats lists the seated accounts in turn // CreateParams describes a new game. Seats lists the seated accounts in turn
// order (seat 0 moves first); lobby/matchmaking assembles it in a later stage. // order (seat 0 moves first); lobby/matchmaking assembles it.
type CreateParams struct { type CreateParams struct {
Variant engine.Variant Variant engine.Variant
Seats []uuid.UUID Seats []uuid.UUID
+106 -11
View File
@@ -110,15 +110,15 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
} }
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact // TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
// seeds the new account's language and display name from the launch fields, // seeds the new account's language, display name and time zone from the launch
// defaults the in-app-only flag on, and never overwrites an existing account on a // fields / detected offset, defaults the in-app-only flag on, and never overwrites
// later login (language seeding). // an existing account on a later login (language and zone seeding).
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString() ext := "tg-" + uuid.NewString()
acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван") acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван", "+03:00")
if err != nil { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
@@ -131,12 +131,15 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
if acc.DisplayName != "Иван" { if acc.DisplayName != "Иван" {
t.Errorf("DisplayName = %q, want Иван", acc.DisplayName) t.Errorf("DisplayName = %q, want Иван", acc.DisplayName)
} }
if acc.TimeZone != "+03:00" {
t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone)
}
if !acc.NotificationsInAppOnly { if !acc.NotificationsInAppOnly {
t.Error("NotificationsInAppOnly should default to true") t.Error("NotificationsInAppOnly should default to true")
} }
// A later login with different fields returns the same account, unchanged. // A later login with different fields returns the same account, unchanged.
again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other") again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other", "+09:00")
if err != nil { if err != nil {
t.Fatalf("re-provision telegram: %v", err) t.Fatalf("re-provision telegram: %v", err)
} }
@@ -146,8 +149,100 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
if again.ID != acc.ID { if again.ID != acc.ID {
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID) t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
} }
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" { if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" || again.TimeZone != "+03:00" {
t.Errorf("existing account overwritten: lang=%q name=%q", again.PreferredLanguage, again.DisplayName) t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone)
}
}
// TestProvisionVKSeedsNewAccountOnly checks VK first contact seeds the new account's
// language, display name and time zone from the launch fields / detected offset, records
// the vk identity as confirmed (a platform identity), and never overwrites an existing
// account on a later launch. It also exercises the widened identities.kind CHECK — a
// 'vk' row must insert.
func TestProvisionVKSeedsNewAccountOnly(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
ext := "vk-" + uuid.NewString()
acc, created, err := store.ProvisionVK(ctx, ext, "ru", "Иван Петров", "+03:00")
if err != nil {
t.Fatalf("provision vk: %v", err)
}
if !created {
t.Error("created = false on first contact, want true")
}
if acc.PreferredLanguage != "ru" {
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
}
if acc.DisplayName != "Иван Петров" {
t.Errorf("DisplayName = %q, want Иван Петров", acc.DisplayName)
}
if acc.TimeZone != "+03:00" {
t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone)
}
// A VK identity is a platform identity: confirmed on insert.
if !identityConfirmed(t, account.KindVK, ext) {
t.Error("vk identity must be confirmed")
}
// A later launch with different fields returns the same account, unchanged.
again, created, err := store.ProvisionVK(ctx, ext, "en", "Other Name", "+09:00")
if err != nil {
t.Fatalf("re-provision vk: %v", err)
}
if created {
t.Error("created = true on a repeat launch, want false")
}
if again.ID != acc.ID {
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
}
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван Петров" || again.TimeZone != "+03:00" {
t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone)
}
}
// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
// valid detected offset is stored verbatim (even "+00:00", which is deliberately
// distinct from the unset "UTC" default), a guest is seeded the same way, and a
// missing or malformed offset falls back to the "UTC" column default rather than
// being guessed at.
func TestProvisionSeedsTimeZone(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
// A detected zero offset is written as "+00:00" — we record that the zone was
// detected (and equals UTC), distinct from the "UTC" default meaning "unknown".
utcDetected, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Zero", "+00:00")
if err != nil {
t.Fatalf("provision telegram +00:00: %v", err)
}
if utcDetected.TimeZone != "+00:00" {
t.Errorf("TimeZone = %q, want the seeded +00:00", utcDetected.TimeZone)
}
// A malformed offset is dropped: the account keeps the UTC default.
bad, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Bad", "not-a-zone")
if err != nil {
t.Fatalf("provision telegram bad tz: %v", err)
}
if bad.TimeZone != "UTC" {
t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone)
}
// A guest is seeded its detected offset; an empty one keeps the UTC default.
guest, err := store.ProvisionGuest(ctx, "-05:30")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
if guest.TimeZone != "-05:30" {
t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone)
}
plainGuest, err := store.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision plain guest: %v", err)
}
if plainGuest.TimeZone != "UTC" {
t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone)
} }
} }
@@ -156,7 +251,7 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
// language CHECK. // language CHECK.
func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) { func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
ctx := context.Background() ctx := context.Background()
acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "") acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "", "")
if err != nil { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
@@ -172,7 +267,7 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
func TestHighRateFlagRoundTrip(t *testing.T) { func TestHighRateFlagRoundTrip(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player", "")
if err != nil { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
@@ -228,7 +323,7 @@ func TestIdentityExternalID(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString() ext := "tg-" + uuid.NewString()
acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User") acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User", "")
if err != nil { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
@@ -253,7 +348,7 @@ func TestIdentityExternalID(t *testing.T) {
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) { func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player", "")
if err != nil { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
+1 -1
View File
@@ -222,7 +222,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
func TestConsoleThrottledViewAndFlagClear(t *testing.T) { func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
ctx := context.Background() ctx := context.Background()
accounts := account.NewStore(testDB) accounts := account.NewStore(testDB)
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player") acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player", "")
if err != nil { if err != nil {
t.Fatalf("provision: %v", err) t.Fatalf("provision: %v", err)
} }
@@ -0,0 +1,83 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// TestRemoveEmailIdentity erases an account's email identity but refuses when the
// email is the account's only identity (which would leave it unreachable).
func TestRemoveEmailIdentity(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
// Email is the only identity → refuse.
solo, err := store.ProvisionEmail(ctx, "solo-"+uuid.NewString()+"@example.com", "", "en")
if err != nil {
t.Fatalf("provision email: %v", err)
}
if err := store.RemoveEmailIdentity(ctx, solo.ID); !errors.Is(err, account.ErrLastIdentity) {
t.Fatalf("remove last identity = %v, want ErrLastIdentity", err)
}
// Telegram + email → erase the email, keep Telegram.
tg, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
if err := store.AttachIdentity(ctx, tg.ID, account.KindEmail, "dual-"+uuid.NewString()+"@example.com", true); err != nil {
t.Fatalf("attach email: %v", err)
}
if err := store.RemoveEmailIdentity(ctx, tg.ID); err != nil {
t.Fatalf("remove email: %v", err)
}
ids, err := store.Identities(ctx, tg.ID)
if err != nil {
t.Fatalf("identities: %v", err)
}
if len(ids) != 1 || ids[0].Kind != account.KindTelegram {
t.Errorf("identities after erase = %+v, want only telegram", ids)
}
}
// TestListUsersEmailExact matches accounts strictly (exactly) by their email identity.
func TestListUsersEmailExact(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
email := "find-" + uuid.NewString() + "@example.com"
acc, err := store.ProvisionEmail(ctx, email, "", "en")
if err != nil {
t.Fatalf("provision: %v", err)
}
items, err := store.ListUsers(ctx, account.UserFilter{EmailExact: email}, 50, 0)
if err != nil {
t.Fatalf("list: %v", err)
}
found := false
for _, it := range items {
if it.ID == acc.ID {
found = true
}
}
if !found {
t.Error("the exact email filter did not find the account")
}
other, err := store.ListUsers(ctx, account.UserFilter{EmailExact: "nope-" + uuid.NewString() + "@example.com"}, 50, 0)
if err != nil {
t.Fatalf("list (no match): %v", err)
}
for _, it := range other {
if it.ID == acc.ID {
t.Error("a non-matching email filter must not return the account")
}
}
}
+142 -2
View File
@@ -192,8 +192,9 @@ func TestBannerMessageOwnership(t *testing.T) {
type profileBanner struct { type profileBanner struct {
Banner *struct { Banner *struct {
Campaigns []struct { Campaigns []struct {
Weight int `json:"weight"` Weight int `json:"weight"`
Messages []string `json:"messages"` Messages []string `json:"messages"`
OverrideBg string `json:"override_bg"`
} `json:"campaigns"` } `json:"campaigns"`
Timings struct { Timings struct {
HoldMs int `json:"hold_ms"` HoldMs int `json:"hold_ms"`
@@ -274,3 +275,142 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
t.Fatalf("profile update dropped the banner block: %v", p.Banner) t.Fatalf("profile update dropped the banner block: %v", p.Banner)
} }
} }
// TestBannerConsoleColorsAndUrgent drives the colour-override + urgent editor over
// HTTP against real Postgres: an incomplete or malformed colour set is refused, a
// full two-set override + urgent round-trips through the store (exercising the new
// columns and CHECK constraints), clearing the sets removes the override, and the
// default campaign stays plain (colours + urgent are ignored for it).
func TestBannerConsoleColorsAndUrgent(t *testing.T) {
ctx := context.Background()
srv, adsSvc := bannerServer(t)
h := srv.Handler()
base := "http://admin.test/_gm"
const origin = "http://admin.test"
name := "Brand-" + uuid.NewString()[:8]
if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created"))
}
id := findCampaign(t, adsSvc, name)
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, id) })
detail := base + "/banners/" + id.String()
// A partial colour set (a missing colour) is refused by validation.
partial := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=%23aa0000&override_fg=&override_link=%23ffdd00"
if code, b := consoleDo(h, http.MethodPost, detail, partial, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
t.Fatalf("partial colour = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
}
// A malformed hex is refused.
badHex := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=red&override_fg=%23ffffff&override_link=%23ffdd00"
if code, b := consoleDo(h, http.MethodPost, detail, badHex, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
t.Fatalf("bad hex = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
}
// Both colour sets + urgent persist and round-trip through the store.
full := "name=" + name + "&weight=20&enabled=on&urgent=on" +
"&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00" +
"&override_dark_on=on&override_bg_dark=%23330000&override_fg_dark=%23eeeeee&override_link_dark=%23ffcc00"
if code, b := consoleDo(h, http.MethodPost, detail, full, origin); code != http.StatusOK || !strings.Contains(b, "Saved") {
t.Fatalf("full save = %d, has Saved=%v", code, strings.Contains(b, "Saved"))
}
cm, err := adsSvc.Campaign(ctx, id)
if err != nil {
t.Fatalf("read campaign: %v", err)
}
if cm.OverrideAll == nil || cm.OverrideAll.Bg != "#aa0000" || cm.OverrideAll.Fg != "#ffffff" || cm.OverrideAll.Link != "#ffdd00" {
t.Fatalf("override all = %+v", cm.OverrideAll)
}
if cm.OverrideDark == nil || cm.OverrideDark.Bg != "#330000" || cm.OverrideDark.Fg != "#eeeeee" || cm.OverrideDark.Link != "#ffcc00" {
t.Fatalf("override dark = %+v", cm.OverrideDark)
}
if !cm.Urgent {
t.Fatal("urgent not persisted")
}
// Clearing the sets (both checkboxes off, urgent off) removes the override.
if code, _ := consoleDo(h, http.MethodPost, detail, "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK {
t.Fatalf("clear = %d", code)
}
if cm, _ := adsSvc.Campaign(ctx, id); cm.OverrideAll != nil || cm.OverrideDark != nil || cm.Urgent {
t.Fatalf("override not cleared: all=%v dark=%v urgent=%v", cm.OverrideAll, cm.OverrideDark, cm.Urgent)
}
// The default campaign stays plain: submitted colours + urgent are ignored for it.
def := defaultCampaign(t, adsSvc)
defForm := "name=Default+%28house%29&urgent=on&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00"
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String(), defForm, origin); code != http.StatusOK {
t.Fatalf("update default = %d", code)
}
if again := defaultCampaign(t, adsSvc); again.OverrideAll != nil || again.OverrideDark != nil || again.Urgent {
t.Fatalf("default not plain: all=%v dark=%v urgent=%v", again.OverrideAll, again.OverrideDark, again.Urgent)
}
}
// TestBannerUrgentBypassesEligibility checks the urgent flag at the profile level:
// an urgent campaign preempts the feed (only it shows, with its colours) and reaches
// every viewer — including the no_banner role and a non-empty hint wallet that
// otherwise suppress the banner.
func TestBannerUrgentBypassesEligibility(t *testing.T) {
ctx := context.Background()
srv, adsSvc := bannerServer(t)
accounts := account.NewStore(testDB)
id := provisionAccount(t)
urgentID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{
Name: "Alert-" + uuid.NewString()[:8], Weight: 10, Enabled: true, Urgent: true,
OverrideAll: &ads.ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"},
})
if err != nil {
t.Fatalf("create urgent: %v", err)
}
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, urgentID) })
if _, err := adsSvc.AddMessage(ctx, urgentID, "System maintenance tonight", "Ночью тех.работы"); err != nil {
t.Fatalf("add urgent message: %v", err)
}
get := func() profileBanner {
t.Helper()
rec := userGet(t, srv, "/api/v1/user/profile", id)
if rec.Code != http.StatusOK {
t.Fatalf("profile = %d", rec.Code)
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode: %v", err)
}
return p
}
assertUrgentOnly := func(what string, p profileBanner) {
t.Helper()
if p.Banner == nil {
t.Fatalf("%s: no banner", what)
}
if len(p.Banner.Campaigns) != 1 {
t.Fatalf("%s: %d campaigns, want only the urgent one (default preempted)", what, len(p.Banner.Campaigns))
}
c := p.Banner.Campaigns[0]
if len(c.Messages) != 1 || c.Messages[0] != "System maintenance tonight" {
t.Fatalf("%s: messages = %v, want only the urgent one", what, c.Messages)
}
if c.OverrideBg != "#aa0000" {
t.Fatalf("%s: override_bg = %q, want #aa0000", what, c.OverrideBg)
}
}
// A normal viewer sees only the urgent campaign (the default is preempted).
assertUrgentOnly("eligible", get())
// The no_banner role no longer suppresses it — urgent reaches everyone.
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("grant role: %v", err)
}
assertUrgentOnly("no_banner", get())
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("revoke role: %v", err)
}
// A non-empty hint wallet no longer suppresses it either.
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
t.Fatalf("grant hints: %v", err)
}
assertUrgentOnly("hints", get())
}
+1 -1
View File
@@ -55,7 +55,7 @@ func TestChatAccessResolver(t *testing.T) {
srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts}) srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts})
ext := "tg-" + uuid.NewString() ext := "tg-" + uuid.NewString()
acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter") acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter", "")
if err != nil { if err != nil {
t.Fatalf("provision: %v", err) t.Fatalf("provision: %v", err)
} }
+59
View File
@@ -138,6 +138,65 @@ func TestNudgeClearedByMove(t *testing.T) {
} }
} }
// TestGameCompletionExpiresNudgesKeepsChat checks that finishing a game by turn-timeout marks every
// pending nudge in it read — the lobby's nudge badge is stale once the game is over — while leaving
// real chat messages unread. It reproduces the reported bug: the timeout path commits the finish
// directly, bypassing the move path's per-mover nudge clear, so without a completion-driven expiry
// the awaited seat's nudge lingered as a badge on the finished game.
func TestGameCompletionExpiresNudgesKeepsChat(t *testing.T) {
ctx := context.Background()
gameSvc := newGameService()
socialSvc := newSocialService()
gameSvc.SetNudgeExpirer(socialSvc.ExpireNudges)
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := gameSvc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create: %v", err)
}
// Seat 1 nudges the to-move seat 0 (the awaited player), and seat 0 posts a real chat message,
// which seat 1 then holds unread. So before the timeout each side has exactly one unread entry:
// seat 0 a nudge, seat 1 a message — letting HasUnread isolate each kind.
if _, err := socialSvc.Nudge(ctx, g.ID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
if _, err := socialSvc.PostMessage(ctx, g.ID, seats[0], "good luck", ""); err != nil {
t.Fatalf("post message: %v", err)
}
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[0]); !unread {
t.Fatal("seat 0 should hold the nudge unread before the timeout")
}
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[1]); !unread {
t.Fatal("seat 1 should hold the message unread before the timeout")
}
// Age the turn past its deadline and time seat 0 out; an empty away window keeps this
// deterministic regardless of the wall clock. The sweep finishes the game through the direct
// commit path, never the move path.
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
setAway(t, seats[0], "UTC", "00:00", "00:00")
if n, err := gameSvc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
t.Fatalf("sweep swept %d (err %v), want >= 1", n, err)
}
if status, reason := gameStatus(t, gameSvc, g.ID); status != game.StatusFinished || reason != "timeout" {
t.Fatalf("game not timed out: status %q reason %q", status, reason)
}
// The nudge is stale on a finished game and must be cleared; the chat message must survive.
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[0]); unread {
t.Error("the nudge should be expired once the game has finished")
}
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[1]); !unread {
t.Error("a real chat message must stay unread after the game finishes")
}
if msg, _ := socialSvc.HasUnreadMessage(ctx, g.ID, seats[1]); !msg {
t.Error("the chat message must remain flagged as an unread message after completion")
}
}
// TestChatToRobotIsBornRead checks a text message to a disguised robot opponent (a pooled // TestChatToRobotIsBornRead checks a text message to a disguised robot opponent (a pooled
// robot substituted into an ordinary, non-AI game) is born read: the robot never opens the // robot substituted into an ordinary, non-AI game) is born read: the robot never opens the
// chat, so the message must not linger unread (skewing the count and the read metric). // chat, so the message must not linger unread (skewing the count and the read metric).
+322
View File
@@ -0,0 +1,322 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountdelete"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// deletedFields reads a tombstoned account's retained real name and its deleted_at.
func deletedFields(t *testing.T, accountID uuid.UUID) (name string, deletedAt sql.NullTime) {
t.Helper()
var dn sql.NullString
err := testDB.QueryRowContext(context.Background(),
"SELECT deleted_display_name, deleted_at FROM accounts WHERE account_id = $1", accountID).
Scan(&dn, &deletedAt)
if err != nil {
t.Fatalf("read deleted fields %s: %v", accountID, err)
}
return dn.String, deletedAt
}
// TestAnonymizeAndTombstone: deletion journals + frees the credentials, tombstones the
// account, scrubs the live name while retaining the real one, and frees the creds for a
// new account to reuse.
func TestAnonymizeAndTombstone(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
deleter := accountdelete.NewDeleter(testDB)
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00")
if err != nil {
t.Fatalf("provision: %v", err)
}
email := "del-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
t.Fatalf("attach email: %v", err)
}
before, err := store.GetByID(ctx, acc.ID)
if err != nil {
t.Fatalf("load before: %v", err)
}
if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
t.Fatalf("delete: %v", err)
}
// The live identities are gone.
if ids, err := store.Identities(ctx, acc.ID); err != nil || len(ids) != 0 {
t.Fatalf("identities after delete = %+v (err %v), want none", ids, err)
}
// Both credentials are journalled with reason=delete.
got := retainedRows(t, acc.ID)
if len(got) != 2 {
t.Fatalf("retained rows = %+v, want 2", got)
}
for _, r := range got {
if r.reason != "delete" {
t.Errorf("retained reason = %q, want delete", r.reason)
}
}
// The live name is scrubbed; the real one is retained; deleted_at is set.
after, err := store.GetByID(ctx, acc.ID)
if err != nil {
t.Fatalf("load after: %v", err)
}
if after.DisplayName != accountdelete.AnonymizedName {
t.Errorf("live display name = %q, want %q", after.DisplayName, accountdelete.AnonymizedName)
}
name, deletedAt := deletedFields(t, acc.ID)
if name != before.DisplayName {
t.Errorf("retained name = %q, want %q", name, before.DisplayName)
}
if !deletedAt.Valid || time.Since(deletedAt.Time) > time.Minute {
t.Errorf("deleted_at = %+v, want a recent timestamp", deletedAt)
}
// The credentials are free: a new account can claim the same email.
other, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Other", "")
if err != nil {
t.Fatalf("provision other: %v", err)
}
if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, email, true); err != nil {
t.Fatalf("email should be free after deletion, got: %v", err)
}
}
// TestDeletionDossierReaders: after deletion the admin readers expose the credential
// journal and the tombstone dossier.
func TestDeletionDossierReaders(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
deleter := accountdelete.NewDeleter(testDB)
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00")
if err != nil {
t.Fatalf("provision: %v", err)
}
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "dos-"+uuid.NewString()+"@example.com", true); err != nil {
t.Fatalf("attach email: %v", err)
}
if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
t.Fatalf("delete: %v", err)
}
rets, err := store.RetainedIdentities(ctx, acc.ID)
if err != nil || len(rets) != 2 {
t.Fatalf("RetainedIdentities = (%+v, %v), want 2 rows", rets, err)
}
for _, r := range rets {
if r.Reason != "delete" {
t.Errorf("retained reason = %q, want delete", r.Reason)
}
}
info, err := store.DeletionInfo(ctx, acc.ID)
if err != nil {
t.Fatalf("DeletionInfo: %v", err)
}
if info.DeletedAt == nil {
t.Error("DeletionInfo.DeletedAt should be set")
}
if info.DeletedDisplayName != "Иван" {
t.Errorf("DeletionInfo.DeletedDisplayName = %q, want Иван", info.DeletedDisplayName)
}
}
// listHasID reports whether the user list contains accountID.
func listHasID(items []account.UserListItem, id uuid.UUID) bool {
for _, it := range items {
if it.ID == id {
return true
}
}
return false
}
// TestUserListDeletedFilter: a tombstoned account is hidden from the default People list and
// shown only under the Deleted scope.
func TestUserListDeletedFilter(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
deleter := accountdelete.NewDeleter(testDB)
live, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Live", "")
if err != nil {
t.Fatalf("provision live: %v", err)
}
goneTg := "tg-" + uuid.NewString()
gone, _, err := store.ProvisionTelegram(ctx, goneTg, "en", "", "Gone", "")
if err != nil {
t.Fatalf("provision gone: %v", err)
}
goneEmail := "gone-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, gone.ID, account.KindEmail, goneEmail, true); err != nil {
t.Fatalf("attach gone email: %v", err)
}
if err := deleter.AnonymizeAndTombstone(ctx, gone.ID); err != nil {
t.Fatalf("delete: %v", err)
}
people, err := store.ListUsers(ctx, account.UserFilter{}, 5000, 0)
if err != nil {
t.Fatalf("list people: %v", err)
}
if listHasID(people, gone.ID) {
t.Error("a deleted account must not appear in the default People list")
}
if !listHasID(people, live.ID) {
t.Error("a live account must appear in the default People list")
}
deleted, err := store.ListUsers(ctx, account.UserFilter{Deleted: true}, 5000, 0)
if err != nil {
t.Fatalf("list deleted: %v", err)
}
if !listHasID(deleted, gone.ID) {
t.Error("a deleted account must appear in the Deleted list")
}
if listHasID(deleted, live.ID) {
t.Error("a live account must not appear in the Deleted list")
}
// A search spans both lists and reaches the retention journal: a deleted account is
// still found by the email and external id it held (both moved to retained_identities
// on deletion, out of the live identities table).
byEmail, err := store.ListUsers(ctx, account.UserFilter{EmailExact: goneEmail}, 5000, 0)
if err != nil {
t.Fatalf("search by email: %v", err)
}
if !listHasID(byEmail, gone.ID) {
t.Error("a deleted account must be found by the email it held (retention journal)")
}
byExt, err := store.ListUsers(ctx, account.UserFilter{ExternalIDMask: goneTg}, 5000, 0)
if err != nil {
t.Fatalf("search by external id: %v", err)
}
if !listHasID(byExt, gone.ID) {
t.Error("a deleted account must be found by the external id it held (retention journal)")
}
}
// TestConfirmCodeClearsGuest: confirming an email on a guest via ConfirmCode promotes it to
// a durable account (defence-in-depth — no confirmed-email path leaves is_guest set).
func TestConfirmCodeClearsGuest(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
email := "cc-" + uuid.NewString() + "@example.com"
if err := svc.RequestCode(ctx, guest.ID, email); err != nil {
t.Fatalf("request code: %v", err)
}
if _, err := svc.ConfirmCode(ctx, guest.ID, email, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("confirm code: %v", err)
}
after, err := store.GetByID(ctx, guest.ID)
if err != nil {
t.Fatalf("load: %v", err)
}
if after.IsGuest {
t.Error("confirming an email must clear the guest flag")
}
}
// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human
// opponent.
func TestDropAllRobotGames(t *testing.T) {
ctx := context.Background()
gsvc := newGameService()
robots := newRobotService(t, gsvc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
mm := newMatchmaker(t, robots, time.Minute, 0)
deleter := accountdelete.NewDeleter(testDB)
user := provisionAccount(t)
other := provisionAccount(t)
aiRes, err := mm.StartVsAI(ctx, user, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("start vs AI: %v", err)
}
humanGame, err := gsvc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{user, other}, TurnTimeout: time.Hour, Seed: 1,
})
if err != nil {
t.Fatalf("create human game: %v", err)
}
n, err := deleter.DropAllRobotGames(ctx, user)
if err != nil {
t.Fatalf("drop: %v", err)
}
if n != 1 {
t.Fatalf("dropped %d games, want 1 (the vs-AI game)", n)
}
if _, err := gsvc.GameByID(ctx, aiRes.Game.ID); err == nil {
t.Error("the vs-AI game should be dropped")
}
if _, err := gsvc.GameByID(ctx, humanGame.ID); err != nil {
t.Errorf("the human game should be kept, got: %v", err)
}
}
// TestDeleteStepUpEmail: an email account's delete code verifies (wrong code rejected, no
// deeplink in the mail); a platform-only account has no email and cannot request a code.
func TestDeleteStepUpEmail(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "del-"+uuid.NewString()+"@example.com", true); err != nil {
t.Fatalf("attach email: %v", err)
}
if has, err := svc.HasEmail(ctx, acc.ID); err != nil || !has {
t.Fatalf("HasEmail = (%v, %v), want true", has, err)
}
if err := svc.RequestDeleteCode(ctx, acc.ID); err != nil {
t.Fatalf("request delete code: %v", err)
}
if strings.Contains(mailer.lastBody, "/confirm/") {
t.Error("a delete email must not carry a one-tap deeplink")
}
code := sixDigit.FindString(mailer.lastBody)
if err := svc.VerifyDeleteCode(ctx, acc.ID, "000000"); err == nil {
t.Error("a wrong delete code must be rejected")
}
if err := svc.VerifyDeleteCode(ctx, acc.ID, code); err != nil {
t.Fatalf("verify correct delete code: %v", err)
}
noEmail, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision no-email: %v", err)
}
if has, _ := svc.HasEmail(ctx, noEmail.ID); has {
t.Error("HasEmail must be false for a platform-only account")
}
if err := svc.RequestDeleteCode(ctx, noEmail.ID); !errors.Is(err, account.ErrNoEmail) {
t.Errorf("request delete for no-email account = %v, want ErrNoEmail", err)
}
}
+220 -10
View File
@@ -19,8 +19,8 @@ import (
// recover the confirm-code from the body. // recover the confirm-code from the body.
type capturingMailer struct{ lastBody string } type capturingMailer struct{ lastBody string }
func (m *capturingMailer) Send(_ context.Context, _, _, body string) error { func (m *capturingMailer) Send(_ context.Context, msg account.Message) error {
m.lastBody = body m.lastBody = msg.Text
return nil return nil
} }
@@ -32,7 +32,7 @@ func TestEmailConfirmFlow(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer) svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc := provisionAccount(t) acc := provisionAccount(t)
email := "user-" + uuid.NewString() + "@example.com" email := "user-" + uuid.NewString() + "@example.com"
@@ -62,12 +62,12 @@ func TestEmailConfirmFlow(t *testing.T) {
} }
// TestEmailAlreadyTakenByAnotherAccount refuses to bind an email confirmed by a // TestEmailAlreadyTakenByAnotherAccount refuses to bind an email confirmed by a
// different account (merge is a later stage). // different account (combining two accounts is the separate link/merge flow).
func TestEmailAlreadyTakenByAnotherAccount(t *testing.T) { func TestEmailAlreadyTakenByAnotherAccount(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer) svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
owner := provisionAccount(t) owner := provisionAccount(t)
email := "taken-" + uuid.NewString() + "@example.com" email := "taken-" + uuid.NewString() + "@example.com"
@@ -89,7 +89,7 @@ func TestEmailCodeExpires(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer) svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc := provisionAccount(t) acc := provisionAccount(t)
email := "expire-" + uuid.NewString() + "@example.com" email := "expire-" + uuid.NewString() + "@example.com"
@@ -111,7 +111,7 @@ func TestEmailTooManyAttempts(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer) svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc := provisionAccount(t) acc := provisionAccount(t)
email := "lock-" + uuid.NewString() + "@example.com" email := "lock-" + uuid.NewString() + "@example.com"
@@ -203,10 +203,10 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
func TestEmailLoginFlow(t *testing.T) { func TestEmailLoginFlow(t *testing.T) {
ctx := context.Background() ctx := context.Background()
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(account.NewStore(testDB), mailer) svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
email := "login-" + uuid.NewString() + "@example.com" email := "login-" + uuid.NewString() + "@example.com"
accountID, err := svc.RequestLoginCode(ctx, email) accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en", false)
if err != nil { if err != nil {
t.Fatalf("request login code: %v", err) t.Fatalf("request login code: %v", err)
} }
@@ -225,12 +225,15 @@ func TestEmailLoginFlow(t *testing.T) {
if acc.IsGuest { if acc.IsGuest {
t.Error("an email account must be durable, not a guest") t.Error("an email account must be durable, not a guest")
} }
if acc.TimeZone != "+02:00" {
t.Errorf("TimeZone = %q, want the +02:00 seeded at the request step", acc.TimeZone)
}
if !identityConfirmed(t, account.KindEmail, email) { if !identityConfirmed(t, account.KindEmail, email) {
t.Error("the email identity must be confirmed after login") t.Error("the email identity must be confirmed after login")
} }
// A second login for the same email is the returning user: same account. // A second login for the same email is the returning user: same account.
if _, err := svc.RequestLoginCode(ctx, email); err != nil { if _, err := svc.RequestLoginCode(ctx, email, "", "ru", false); err != nil {
t.Fatalf("second request: %v", err) t.Fatalf("second request: %v", err)
} }
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)) acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
@@ -241,3 +244,210 @@ func TestEmailLoginFlow(t *testing.T) {
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID) t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
} }
} }
// TestEmailLoginProvisionsGuestUntilConfirmed covers the squat fix: an email-login
// account is a guest (reapable, so an abandoned never-confirmed login frees its
// address) until the code is confirmed, which promotes it to a durable account.
func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "squat-" + uuid.NewString() + "@example.com"
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
if err != nil {
t.Fatalf("request login code: %v", err)
}
before, err := store.GetByID(ctx, id)
if err != nil {
t.Fatalf("get before confirm: %v", err)
}
if !before.IsGuest {
t.Error("an unconfirmed email-login account must be a guest so it is reapable")
}
if _, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("login: %v", err)
}
after, err := store.GetByID(ctx, id)
if err != nil {
t.Fatalf("get after confirm: %v", err)
}
if after.IsGuest {
t.Error("confirming the login must clear the guest flag (promote to durable)")
}
}
// TestEmailLoginPWAOmitsLink: a login requested from an installed PWA (pwa=true) mails only the
// code — no one-tap confirm link, which would otherwise open in a separate browser out of the
// PWA's reach. A non-PWA request keeps the link.
func TestEmailLoginPWAOmitsLink(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
pwaEmail := "pwa-login-" + uuid.NewString() + "@example.com"
if _, err := svc.RequestLoginCode(ctx, pwaEmail, "", "en", true); err != nil {
t.Fatalf("pwa request login: %v", err)
}
if sixDigit.FindString(mailer.lastBody) == "" {
t.Errorf("pwa login mail must still carry the code, body %q", mailer.lastBody)
}
if confirmToken.MatchString(mailer.lastBody) {
t.Errorf("pwa login mail must omit the one-tap confirm link, body %q", mailer.lastBody)
}
webEmail := "web-login-" + uuid.NewString() + "@example.com"
if _, err := svc.RequestLoginCode(ctx, webEmail, "", "en", false); err != nil {
t.Fatalf("web request login: %v", err)
}
if !confirmToken.MatchString(mailer.lastBody) {
t.Errorf("non-pwa login mail must keep the one-tap confirm link, body %q", mailer.lastBody)
}
}
// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/<token> link
// the branded email carries.
var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`)
func tokenFromMail(t *testing.T, body string) string {
t.Helper()
m := confirmToken.FindStringSubmatch(body)
if m == nil {
t.Fatalf("no confirm token in mail body %q", body)
}
return m[1]
}
// TestConfirmByTokenLogin: the one-tap deeplink token completes an email login,
// clearing the guest flag.
func TestConfirmByTokenLogin(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "tok-login-" + uuid.NewString() + "@example.com"
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
if err != nil {
t.Fatalf("request login: %v", err)
}
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
if err != nil {
t.Fatalf("confirm by token: %v", err)
}
if !res.IsLogin() || res.Account != id {
t.Fatalf("login result = %+v, want login for %s", res, id)
}
if !identityConfirmed(t, account.KindEmail, email) {
t.Error("email identity must be confirmed after the token login")
}
if acc, _ := store.GetByID(ctx, id); acc.IsGuest {
t.Error("the token login must clear the guest flag")
}
}
// TestConfirmByTokenLink: the token attaches a free email to the requesting account.
func TestConfirmByTokenLink(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc := provisionAccount(t)
email := "tok-link-" + uuid.NewString() + "@example.com"
if err := svc.RequestLinkCode(ctx, acc, email); err != nil {
t.Fatalf("request link: %v", err)
}
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
if err != nil {
t.Fatalf("confirm by token: %v", err)
}
if res.IsLogin() || res.NeedsMerge || res.Account != acc {
t.Fatalf("link result = %+v, want a plain link for %s", res, acc)
}
if !identityConfirmed(t, account.KindEmail, email) {
t.Error("email identity must be confirmed after the token link")
}
}
// TestConfirmByTokenLinkMerge: a token for an address owned by another account signals
// a merge (leaving the token unconsumed for the interactive step).
func TestConfirmByTokenLinkMerge(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "tok-merge-" + uuid.NewString() + "@example.com"
owner := provisionAccount(t)
if err := svc.RequestCode(ctx, owner, email); err != nil {
t.Fatalf("owner request: %v", err)
}
if _, err := svc.ConfirmCode(ctx, owner, email, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("owner confirm: %v", err)
}
other := provisionAccount(t)
if err := svc.RequestLinkCode(ctx, other, email); err != nil {
t.Fatalf("link request: %v", err)
}
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
if err != nil {
t.Fatalf("confirm by token: %v", err)
}
if !res.NeedsMerge || res.MergeOwner != owner {
t.Fatalf("merge result = %+v, want NeedsMerge with owner=%s", res, owner)
}
}
// TestEmailAccountSeedsDisplayName seeds a new email account's display name from the
// email's local part, so an email login is not left nameless.
func TestEmailAccountSeedsDisplayName(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru")
local := "kaya-" + uuid.NewString()[:8]
id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en", false)
if err != nil {
t.Fatalf("request login: %v", err)
}
acc, err := store.GetByID(ctx, id)
if err != nil {
t.Fatalf("get: %v", err)
}
if acc.DisplayName != local {
t.Errorf("display name = %q, want the email local part %q", acc.DisplayName, local)
}
}
// TestConfirmByTokenLinkClearsGuest: binding an email to a guest via the deeplink
// promotes the guest to a durable account (the deeplink path must match the
// code-based link flow, which clears the guest flag).
func TestConfirmByTokenLinkClearsGuest(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
email := "guest-link-" + uuid.NewString() + "@example.com"
if err := svc.RequestLinkCode(ctx, guest.ID, email); err != nil {
t.Fatalf("request link: %v", err)
}
if _, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody)); err != nil {
t.Fatalf("confirm by token: %v", err)
}
acc, err := store.GetByID(ctx, guest.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if acc.IsGuest {
t.Error("linking an email via the deeplink must promote the guest to durable")
}
}
+13 -13
View File
@@ -38,7 +38,7 @@ func latestFeedbackID(t *testing.T, svc *feedback.Service, acc uuid.UUID) uuid.U
func TestFeedbackGuestRejected(t *testing.T) { func TestFeedbackGuestRejected(t *testing.T) {
svc := newFeedbackService() svc := newFeedbackService()
guest := provisionGuest(t) guest := provisionGuest(t)
if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) { if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "v1", "+05:00", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) {
t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err) t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err)
} }
} }
@@ -48,11 +48,11 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
svc := newFeedbackService() svc := newFeedbackService()
acc := provisionAccount(t) acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "9.9.9.9"); err != nil { if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "v1.2.0", "+03:00", "9.9.9.9"); err != nil {
t.Fatalf("submit: %v", err) t.Fatalf("submit: %v", err)
} }
// Anti-spam gate: a second message is refused while the first is unreviewed. // Anti-spam gate: a second message is refused while the first is unreviewed.
if err := svc.Submit(ctx, acc, "again", nil, "", "web", ""); !errors.Is(err, feedback.ErrPendingReview) { if err := svc.Submit(ctx, acc, "again", nil, "", "web", "", "", ""); !errors.Is(err, feedback.ErrPendingReview) {
t.Fatalf("second submit err = %v, want ErrPendingReview", err) t.Fatalf("second submit err = %v, want ErrPendingReview", err)
} }
if st, err := svc.State(ctx, acc); err != nil { if st, err := svc.State(ctx, acc); err != nil {
@@ -69,7 +69,7 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
if m.Body != "please fix the board" { // trimmed if m.Body != "please fix the board" { // trimmed
t.Fatalf("body = %q, want trimmed", m.Body) t.Fatalf("body = %q, want trimmed", m.Body)
} }
if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" { if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" || m.Version != "v1.2.0" || m.BrowserTZ != "+03:00" {
t.Fatalf("admin message = %+v", m) t.Fatalf("admin message = %+v", m)
} }
if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" { if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" {
@@ -116,7 +116,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
acc := provisionAccount(t) acc := provisionAccount(t)
// msg1, replied → the player can send again and currently sees the reply. // msg1, replied → the player can send again and currently sees the reply.
if err := svc.Submit(ctx, acc, "first", nil, "", "web", ""); err != nil { if err := svc.Submit(ctx, acc, "first", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit msg1: %v", err) t.Fatalf("submit msg1: %v", err)
} }
if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil { if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil {
@@ -130,7 +130,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
// Sending a new message immediately drops the previous reply (it now belongs to an // Sending a new message immediately drops the previous reply (it now belongs to an
// older message), even though it is well within the one-week window. // older message), even though it is well within the one-week window.
if err := svc.Submit(ctx, acc, "second", nil, "", "web", ""); err != nil { if err := svc.Submit(ctx, acc, "second", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit msg2: %v", err) t.Fatalf("submit msg2: %v", err)
} }
st, err := svc.State(ctx, acc) st, err := svc.State(ctx, acc)
@@ -154,7 +154,7 @@ func TestFeedbackSnapshotsLanguage(t *testing.T) {
t.Fatalf("set language: %v", err) t.Fatalf("set language: %v", err)
} }
// A message snapshots the sender's interface language at submit time. // A message snapshots the sender's interface language at submit time.
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil { if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", "", "", ""); err != nil {
t.Fatalf("submit: %v", err) t.Fatalf("submit: %v", err)
} }
id := latestFeedbackID(t, svc, acc) id := latestFeedbackID(t, svc, acc)
@@ -184,7 +184,7 @@ func TestFeedbackBanRole(t *testing.T) {
if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil { if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
t.Fatalf("grant role: %v", err) t.Fatalf("grant role: %v", err)
} }
if err := svc.Submit(ctx, acc, "hi", nil, "", "web", ""); !errors.Is(err, feedback.ErrBanned) { if err := svc.Submit(ctx, acc, "hi", nil, "", "web", "", "", ""); !errors.Is(err, feedback.ErrBanned) {
t.Fatalf("banned submit err = %v, want ErrBanned", err) t.Fatalf("banned submit err = %v, want ErrBanned", err)
} }
if st, err := svc.State(ctx, acc); err != nil { if st, err := svc.State(ctx, acc); err != nil {
@@ -196,7 +196,7 @@ func TestFeedbackBanRole(t *testing.T) {
if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil { if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
t.Fatalf("revoke role: %v", err) t.Fatalf("revoke role: %v", err)
} }
if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", ""); err != nil { if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit after unban: %v", err) t.Fatalf("submit after unban: %v", err)
} }
} }
@@ -219,7 +219,7 @@ func TestFeedbackValidation(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
acc := provisionAccount(t) // fresh account so the pending gate never fires first acc := provisionAccount(t) // fresh account so the pending gate never fires first
if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", ""); !errors.Is(err, tt.want) { if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", "", "", ""); !errors.Is(err, tt.want) {
t.Fatalf("submit err = %v, want %v", err, tt.want) t.Fatalf("submit err = %v, want %v", err, tt.want)
} }
}) })
@@ -231,7 +231,7 @@ func TestFeedbackAdminLifecycle(t *testing.T) {
svc := newFeedbackService() svc := newFeedbackService()
acc := provisionAccount(t) acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, "first report", nil, "", "web", ""); err != nil { if err := svc.Submit(ctx, acc, "first report", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit: %v", err) t.Fatalf("submit: %v", err)
} }
id := latestFeedbackID(t, svc, acc) id := latestFeedbackID(t, svc, acc)
@@ -276,7 +276,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) {
svc := newFeedbackService() svc := newFeedbackService()
acc := provisionAccount(t) acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, "one", nil, "", "web", ""); err != nil { if err := svc.Submit(ctx, acc, "one", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit: %v", err) t.Fatalf("submit: %v", err)
} }
if err := svc.DeleteAllByAccount(ctx, acc); err != nil { if err := svc.DeleteAllByAccount(ctx, acc); err != nil {
@@ -286,7 +286,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) {
if has, err := svc.ReplyUnread(ctx, acc); err != nil || has { if has, err := svc.ReplyUnread(ctx, acc); err != nil || has {
t.Fatalf("reply unread after delete-all = %v (err %v)", has, err) t.Fatalf("reply unread after delete-all = %v (err %v)", has, err)
} }
if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", ""); err != nil { if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit after delete-all: %v", err) t.Fatalf("submit after delete-all: %v", err)
} }
} }
+1 -1
View File
@@ -120,7 +120,7 @@ func provisionAccount(t *testing.T) uuid.UUID {
// provisionGuest creates a fresh ephemeral guest account and returns its id. // provisionGuest creates a fresh ephemeral guest account and returns its id.
func provisionGuest(t *testing.T) uuid.UUID { func provisionGuest(t *testing.T) uuid.UUID {
t.Helper() t.Helper()
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background()) acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "")
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
@@ -0,0 +1,174 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// emailOf returns the external id of the account's email identity, or "" when it has none.
func emailOf(t *testing.T, store *account.Store, id uuid.UUID) string {
t.Helper()
ids, err := store.Identities(context.Background(), id)
if err != nil {
t.Fatalf("identities %s: %v", id, err)
}
for _, i := range ids {
if i.Kind == account.KindEmail {
return i.ExternalID
}
}
return ""
}
// TestUnlinkProviderKeepsOthers detaches one provider from a multi-identity account and
// refuses to remove the last remaining identity.
func TestUnlinkProviderKeepsOthers(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
email := "unlink-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
t.Fatalf("attach email: %v", err)
}
// Removing a kind the account does not hold reports not-found.
if err := store.RemoveIdentity(ctx, acc.ID, account.KindVK); !errors.Is(err, account.ErrNotFound) {
t.Fatalf("remove absent vk = %v, want ErrNotFound", err)
}
// Detach Telegram: the email identity remains.
if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
t.Fatalf("remove telegram: %v", err)
}
ids, err := store.Identities(ctx, acc.ID)
if err != nil {
t.Fatalf("identities: %v", err)
}
if len(ids) != 1 || ids[0].Kind != account.KindEmail {
t.Fatalf("identities after unlink = %+v, want only email", ids)
}
// The email is now the last identity, so unlinking it is refused.
if err := store.RemoveIdentity(ctx, acc.ID, account.KindEmail); !errors.Is(err, account.ErrLastIdentity) {
t.Fatalf("remove last email = %v, want ErrLastIdentity", err)
}
}
// TestChangeEmailReplaces switches an account's confirmed email to a free address,
// freeing the old one.
func TestChangeEmailReplaces(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
oldAddr := "old-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil {
t.Fatalf("attach old email: %v", err)
}
newAddr := "new-" + uuid.NewString() + "@example.com"
if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
t.Fatalf("request change: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, code); err != nil {
t.Fatalf("confirm change: %v", err)
}
if got := emailOf(t, store, acc.ID); got != newAddr {
t.Fatalf("email after change = %q, want %q", got, newAddr)
}
if !identityConfirmed(t, account.KindEmail, newAddr) {
t.Error("the new email identity must be confirmed")
}
// The old address is freed: another account can now claim it.
other, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision other: %v", err)
}
if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, oldAddr, true); err != nil {
t.Fatalf("old address should be free after change, got: %v", err)
}
}
// TestChangeEmailRefusesTaken refuses (without merging) a new address already confirmed by
// another account, leaving the caller's email untouched.
func TestChangeEmailRefusesTaken(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision caller: %v", err)
}
mine := "mine-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, mine, true); err != nil {
t.Fatalf("attach caller email: %v", err)
}
other, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision other: %v", err)
}
taken := "taken-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, taken, true); err != nil {
t.Fatalf("attach other email: %v", err)
}
if err := svc.RequestChangeCode(ctx, acc.ID, taken); err != nil {
t.Fatalf("request change: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := svc.ConfirmChange(ctx, acc.ID, taken, code); !errors.Is(err, account.ErrEmailTaken) {
t.Fatalf("confirm change to taken = %v, want ErrEmailTaken", err)
}
if got := emailOf(t, store, acc.ID); got != mine {
t.Errorf("caller email after refused change = %q, want unchanged %q", got, mine)
}
}
// TestChangeEmailViaDeeplink switches the email through the one-tap confirm token.
func TestChangeEmailViaDeeplink(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "old-"+uuid.NewString()+"@example.com", true); err != nil {
t.Fatalf("attach old email: %v", err)
}
newAddr := "dl-" + uuid.NewString() + "@example.com"
if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
t.Fatalf("request change: %v", err)
}
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
if err != nil {
t.Fatalf("confirm by token: %v", err)
}
if res.IsLogin() || res.NeedsMerge || res.Account != acc.ID {
t.Fatalf("deeplink change result = %+v, want a plain change for %s", res, acc.ID)
}
if got := emailOf(t, store, acc.ID); got != newAddr {
t.Fatalf("email after deeplink change = %q, want %q", got, newAddr)
}
}
+100 -1
View File
@@ -119,7 +119,7 @@ func seatGame(t *testing.T, seats []uuid.UUID, timeout time.Duration) uuid.UUID
func newLinkService(mailer account.Mailer) *link.Service { func newLinkService(mailer account.Mailer) *link.Service {
store := account.NewStore(testDB) store := account.NewStore(testDB)
emails := account.NewEmailService(store, mailer) emails := account.NewEmailService(store, mailer, "https://erudit-game.ru")
sessions := session.NewService(session.NewStore(testDB), session.NewCache()) sessions := session.NewService(session.NewStore(testDB), session.NewCache())
return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions) return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
} }
@@ -251,6 +251,44 @@ func TestAccountMergeFinishedSharedGameKept(t *testing.T) {
} }
} }
// TestAccountMergeDedupesEmail keeps the primary's email when both accounts have one and
// journals the secondary's to the dossier (reason=merge), so the survivor never ends up
// with two email identities.
func TestAccountMergeDedupesEmail(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
merger := accountmerge.NewMerger(testDB)
primary := provisionAccount(t)
secondary := provisionAccount(t)
primaryEmail := "keep-" + uuid.NewString() + "@example.com"
secondaryEmail := "absorb-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, primary, primaryEmail)
bindEmailIdentity(t, secondary, secondaryEmail)
if err := merger.Merge(ctx, primary, secondary); err != nil {
t.Fatalf("merge: %v", err)
}
// The primary keeps its own email; the secondary's is gone from the live identities.
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, primaryEmail); !ok || owner != primary {
t.Errorf("primary email owner = %s ok=%v, want primary %s", owner, ok, primary)
}
if _, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, secondaryEmail); ok {
t.Error("the secondary's email must be removed (no duplicate email on the survivor)")
}
// The absorbed email stays in the legal dossier, tagged reason=merge.
var reason string
if err := testDB.QueryRowContext(ctx,
`SELECT reason FROM backend.retained_identities WHERE account_id=$1 AND kind='email' AND external_id=$2`,
secondary, secondaryEmail).Scan(&reason); err != nil {
t.Fatalf("retained email row: %v", err)
}
if reason != "merge" {
t.Errorf("retained reason = %q, want merge", reason)
}
}
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable. // TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
func TestAccountLinkFreeEmail(t *testing.T) { func TestAccountLinkFreeEmail(t *testing.T) {
ctx := context.Background() ctx := context.Background()
@@ -355,3 +393,64 @@ func TestAccountLinkGuestInversion(t *testing.T) {
t.Errorf("email owner = %s, want durable", owner) t.Errorf("email owner = %s, want durable", owner)
} }
} }
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
links := newLinkService(&capturingMailer{})
guest := provisionGuest(t)
vkID := "vk-" + uuid.NewString()
res, err := links.ConfirmVK(ctx, guest, vkID)
if err != nil {
t.Fatalf("confirm vk: %v", err)
}
if !res.Linked || res.MergeRequired {
t.Fatalf("confirm = %+v, want linked", res)
}
if acc, _ := store.GetByID(ctx, guest); acc.IsGuest {
t.Error("guest flag should clear once VK is linked")
}
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); !ok || owner != guest {
t.Errorf("vk owner = %s, want the promoted guest %s", owner, guest)
}
}
// TestAccountLinkVKMergeIntoCaller merges the account owning a VK identity into the
// current durable account: ConfirmVK previews the merge, MergeVK folds it, the caller
// stays primary and keeps its session, and the VK identity repoints to the caller.
func TestAccountLinkVKMergeIntoCaller(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
links := newLinkService(&capturingMailer{})
caller := provisionAccount(t)
other := provisionAccount(t)
vkID := "vk-" + uuid.NewString()
if err := store.AttachIdentity(ctx, other, account.KindVK, vkID, true); err != nil {
t.Fatalf("seed vk on other: %v", err)
}
confirm, err := links.ConfirmVK(ctx, caller, vkID)
if err != nil {
t.Fatalf("confirm vk: %v", err)
}
if !confirm.MergeRequired || confirm.SecondaryID != other {
t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
}
merge, err := links.MergeVK(ctx, caller, vkID)
if err != nil {
t.Fatalf("merge vk: %v", err)
}
if merge.PrimaryID != caller || merge.SwitchedToken != "" {
t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
}
if mergedInto(t, other) != caller {
t.Error("other should be tombstoned into caller")
}
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); owner != caller {
t.Errorf("vk owner = %s, want caller after merge", owner)
}
}
@@ -0,0 +1,80 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/google/uuid"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/server"
"scrabble/backend/internal/session"
)
// TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint
// to confirm a promo deep-link start-param seeds a brand-new account's variant
// preferences (English Scrabble alongside the default Erudit), that a new account with no
// such payload keeps the Erudit-only default, and that an existing account is never
// re-seeded on a later login (the new-user-only contract).
func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: account.NewStore(testDB),
Sessions: session.NewService(session.NewStore(testDB), session.NewCache()),
Notifier: &captureNotifier{},
})
h := srv.Handler()
post := func(ext, startParam string) {
body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"`
if startParam != "" {
body += `,"start_param":"` + startParam + `"`
}
body += `}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("telegram auth = %d: %s", rec.Code, rec.Body.String())
}
}
store := account.NewStore(testDB)
reload := func(ext string) []string {
acc, err := store.AccountByIdentity(context.Background(), account.KindTelegram, ext)
if err != nil {
t.Fatalf("lookup %s: %v", ext, err)
}
return acc.VariantPreferences
}
// A brand-new account reached through a promo deep-link is seeded with English
// Scrabble alongside the default Erudit.
promoExt := "tg-" + uuid.NewString()
post(promoExt, "verudit_ru-scrabble_en")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Errorf("promo new account variants = %v, want %v", got, want)
}
// A brand-new account with no promo payload keeps the Erudit-only default.
plainExt := "tg-" + uuid.NewString()
post(plainExt, "")
if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) {
t.Errorf("plain new account variants = %v, want %v", got, want)
}
// A later login of the promo account, even via a different payload, must not re-seed:
// the seed is first-contact only.
post(promoExt, "vscrabble_ru")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Errorf("existing account re-seeded = %v, want unchanged %v", got, want)
}
}
+192
View File
@@ -0,0 +1,192 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountdelete"
)
// lastLoginIP reads an account's stamped last-login IP ("" when unset).
func lastLoginIP(t *testing.T, accountID uuid.UUID) string {
t.Helper()
var ip sql.NullString
err := testDB.QueryRowContext(context.Background(),
"SELECT last_login_ip FROM accounts WHERE account_id = $1", accountID).Scan(&ip)
if err != nil {
t.Fatalf("read last_login_ip %s: %v", accountID, err)
}
return ip.String
}
// retainedRow is one row of the retention journal, read directly for assertions.
type retainedRow struct {
kind, externalID, reason string
}
// retainedRows reads the retention journal for an account, oldest detach first.
func retainedRows(t *testing.T, accountID uuid.UUID) []retainedRow {
t.Helper()
rows, err := testDB.QueryContext(context.Background(),
"SELECT kind, external_id, reason FROM retained_identities WHERE account_id = $1 ORDER BY detached_at",
accountID)
if err != nil {
t.Fatalf("query retained_identities %s: %v", accountID, err)
}
defer rows.Close()
var out []retainedRow
for rows.Next() {
var r retainedRow
if err := rows.Scan(&r.kind, &r.externalID, &r.reason); err != nil {
t.Fatalf("scan retained row: %v", err)
}
out = append(out, r)
}
return out
}
// TestUnlinkJournalsRetainedIdentity: detaching a provider records it in the retention
// journal (reason=unlink) before the live identity is removed.
func TestUnlinkJournalsRetainedIdentity(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
tgExt := "tg-" + uuid.NewString()
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt)
if err != nil {
t.Fatalf("provision: %v", err)
}
email := "keep-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
t.Fatalf("attach email: %v", err)
}
if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
t.Fatalf("unlink telegram: %v", err)
}
got := retainedRows(t, acc.ID)
if len(got) != 1 || got[0].kind != account.KindTelegram || got[0].externalID != tgExt || got[0].reason != "unlink" {
t.Fatalf("retained rows = %+v, want one unlink telegram %q", got, tgExt)
}
}
// TestChangeEmailJournalsOldAddress: an email change records the outgoing address in the
// retention journal (reason=change).
func TestChangeEmailJournalsOldAddress(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
oldAddr := "old-" + uuid.NewString() + "@example.com"
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil {
t.Fatalf("attach old email: %v", err)
}
newAddr := "new-" + uuid.NewString() + "@example.com"
if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil {
t.Fatalf("request change: %v", err)
}
if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("confirm change: %v", err)
}
got := retainedRows(t, acc.ID)
if len(got) != 1 || got[0].kind != account.KindEmail || got[0].externalID != oldAddr || got[0].reason != "change" {
t.Fatalf("retained rows = %+v, want one change email %q", got, oldAddr)
}
}
// TestStampLastLoginThrottles: the first cold-load stamp writes the IP; a second within
// the hour is a no-op (throttled), so it costs at most one write per account per hour.
func TestStampLastLoginThrottles(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
if err := store.StampLastLogin(ctx, acc.ID, "1.2.3.4"); err != nil {
t.Fatalf("first stamp: %v", err)
}
if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" {
t.Fatalf("first stamp ip = %q, want 1.2.3.4", got)
}
if err := store.StampLastLogin(ctx, acc.ID, "9.9.9.9"); err != nil {
t.Fatalf("second stamp: %v", err)
}
if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" {
t.Fatalf("throttled ip = %q, want unchanged 1.2.3.4", got)
}
}
// TestReapExpiredRetention: the reaper keeps journal rows newer than the cutoff and purges
// older ones, and drops a long-deleted account's feedback thread + dossier PII.
func TestReapExpiredRetention(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
deleter := accountdelete.NewDeleter(testDB)
// An unlinked provider leaves a journal row detached "now".
tgExt := "tg-" + uuid.NewString()
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt)
if err != nil {
t.Fatalf("provision: %v", err)
}
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "keep-"+uuid.NewString()+"@example.com", true); err != nil {
t.Fatalf("attach email: %v", err)
}
if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
t.Fatalf("unlink: %v", err)
}
// A cutoff before the detach keeps the row.
if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(-time.Hour)); err != nil {
t.Fatalf("reap (early cutoff): %v", err)
}
if got := retainedRows(t, acc.ID); len(got) != 1 {
t.Fatalf("journal after early-cutoff reap = %+v, want kept", got)
}
// A cutoff after the detach purges it.
if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("reap (late cutoff): %v", err)
}
if got := retainedRows(t, acc.ID); len(got) != 0 {
t.Fatalf("journal after late-cutoff reap = %+v, want purged", got)
}
// A deleted account past the cutoff loses its feedback thread and dossier PII.
del, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Иван", "")
if err != nil {
t.Fatalf("provision deletee: %v", err)
}
if _, err := testDB.ExecContext(ctx,
"INSERT INTO feedback_messages (message_id, account_id, body, channel) VALUES ($1, $2, 'hi', 'web')",
uuid.New(), del.ID); err != nil {
t.Fatalf("insert feedback: %v", err)
}
if err := deleter.AnonymizeAndTombstone(ctx, del.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, fb, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil || fb == 0 {
t.Fatalf("reap deleted = (fb %d, err %v), want fb>=1", fb, err)
}
if name, _ := deletedFields(t, del.ID); name != "" {
t.Errorf("deleted_display_name after reap = %q, want cleared", name)
}
var fbCount int
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM feedback_messages WHERE account_id = $1", del.ID).Scan(&fbCount); err != nil {
t.Fatalf("count feedback: %v", err)
}
if fbCount != 0 {
t.Errorf("feedback rows after reap = %d, want 0", fbCount)
}
}
@@ -38,7 +38,7 @@ func TestSuspensionGate(t *testing.T) {
Accounts: accounts, Accounts: accounts,
}) })
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked") acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked", "")
if err != nil { if err != nil {
t.Fatalf("provision: %v", err) t.Fatalf("provision: %v", err)
} }
+2 -2
View File
@@ -18,7 +18,7 @@ func TestUserListFilter(t *testing.T) {
st := account.NewStore(testDB) st := account.NewStore(testDB)
uniq := uuid.NewString() uniq := uuid.NewString()
human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman") human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman", "")
if err != nil { if err != nil {
t.Fatalf("provision human: %v", err) t.Fatalf("provision human: %v", err)
} }
@@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("provision robot: %v", err) t.Fatalf("provision robot: %v", err)
} }
guest, err := st.ProvisionGuest(ctx) guest, err := st.ProvisionGuest(ctx, "")
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
+47
View File
@@ -133,6 +133,53 @@ func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, extern
return s.accounts.ClearGuest(ctx, callerID) return s.accounts.ClearGuest(ctx, callerID)
} }
// ConfirmVK attaches a gateway-validated VK identity to the caller (Linked) or
// reports that it belongs to another account (MergeRequired). The gateway has already
// completed the VK ID code exchange, so externalID is the trusted vk user id.
func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID string) (ConfirmResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
if err != nil {
return ConfirmResult{}, err
}
if !ok {
if err := s.attachVK(ctx, callerID, externalID); err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Linked: true}, nil
}
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
// (subject to the guest-primary rule).
func (s *Service) MergeVK(ctx context.Context, callerID uuid.UUID, externalID string) (MergeResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
if err != nil {
return MergeResult{}, err
}
if !ok {
if err := s.attachVK(ctx, callerID, externalID); err != nil {
return MergeResult{}, err
}
return MergeResult{PrimaryID: callerID}, nil
}
if owner == callerID {
return MergeResult{PrimaryID: callerID}, nil
}
return s.merge(ctx, callerID, owner)
}
// attachVK links the identity to the caller and promotes a guest.
func (s *Service) attachVK(ctx context.Context, callerID uuid.UUID, externalID string) error {
if err := s.accounts.AttachIdentity(ctx, callerID, account.KindVK, externalID, true); err != nil {
return err
}
return s.accounts.ClearGuest(ctx, callerID)
}
// merge decides the primary (the caller, unless it is a guest and the other is // merge decides the primary (the caller, unless it is a guest and the other is
// durable), runs the data merge, retires the secondary's sessions and mints a new // durable), runs the data merge, retires the secondary's sessions and mints a new
// session when the active account switches. // session when the active account switches.
+7
View File
@@ -155,6 +155,13 @@ func Notification(userID uuid.UUID, kind string) Intent {
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()} return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
} }
// ProfileChanged is a payload-free "re-fetch your profile" signal to userID, emitted
// when the viewer's own account changed out of band — an email confirmed through the
// one-tap deeplink opened in another browser.
func ProfileChanged(userID uuid.UUID) Intent {
return Notification(userID, NotifyProfile)
}
// NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the // NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the
// account it concerns (the requester, the new friend or the decliner), so the client updates its // account it concerns (the requester, the new friend or the decliner), so the client updates its
// requests/friends lists and the in-game "add friend" state without a refetch. // requests/friends lists and the in-game "add friend" state without a refetch.
+4
View File
@@ -69,6 +69,10 @@ const (
// it re-fetches profile.get to show or hide the banner. It carries no payload (the // it re-fetches profile.get to show or hide the banner. It carries no payload (the
// banner set rides the profile response). In-app only. // banner set rides the profile response). In-app only.
NotifyBanner = "banner" NotifyBanner = "banner"
// NotifyProfile tells the client that the viewer's own profile changed out of band
// (e.g. an email was confirmed via the one-tap deeplink opened in another browser),
// so it re-fetches profile.get. It carries no payload. In-app only.
NotifyProfile = "profile"
// NotifyUserBlocked confirms to the blocker that a per-user block took effect, // NotifyUserBlocked confirms to the blocker that a per-user block took effect,
// carrying the blocked account, so every one of the blocker's sessions updates the // carrying the blocked account, so every one of the blocker's sessions updates the
// in-game block/add-friend controls and the struck name in place. It is delivered // in-game block/add-friend controls and the struck name in place. It is delivered
@@ -32,4 +32,8 @@ type Accounts struct {
MergedAt *time.Time MergedAt *time.Time
FlaggedHighRateAt *time.Time FlaggedHighRateAt *time.Time
VariantPreferences pq.StringArray VariantPreferences pq.StringArray
LastLoginAt *time.Time
LastLoginIP *string
DeletedAt *time.Time
DeletedDisplayName *string
} }
@@ -13,13 +13,20 @@ import (
) )
type AdCampaigns struct { type AdCampaigns struct {
CampaignID uuid.UUID `sql:"primary_key"` CampaignID uuid.UUID `sql:"primary_key"`
Name string Name string
Weight int32 Weight int32
IsDefault bool IsDefault bool
Enabled bool Enabled bool
StartsAt *time.Time StartsAt *time.Time
EndsAt *time.Time EndsAt *time.Time
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
OverrideBg *string
OverrideFg *string
OverrideLink *string
OverrideBgDark *string
OverrideFgDark *string
OverrideLinkDark *string
Urgent bool
} }
@@ -21,4 +21,6 @@ type EmailConfirmations struct {
Attempts int16 Attempts int16
ConsumedAt *time.Time ConsumedAt *time.Time
CreatedAt time.Time CreatedAt time.Time
Purpose string
LinkTokenHash *string
} }
@@ -0,0 +1,24 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type RetainedIdentities struct {
RetainedID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
Kind string
ExternalID string
Confirmed bool
LinkedAt time.Time
DetachedAt time.Time
Reason string
}
@@ -35,6 +35,10 @@ type accountsTable struct {
MergedAt postgres.ColumnTimestampz MergedAt postgres.ColumnTimestampz
FlaggedHighRateAt postgres.ColumnTimestampz FlaggedHighRateAt postgres.ColumnTimestampz
VariantPreferences postgres.ColumnStringArray VariantPreferences postgres.ColumnStringArray
LastLoginAt postgres.ColumnTimestampz
LastLoginIP postgres.ColumnString
DeletedAt postgres.ColumnTimestampz
DeletedDisplayName postgres.ColumnString
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -94,8 +98,12 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
MergedAtColumn = postgres.TimestampzColumn("merged_at") MergedAtColumn = postgres.TimestampzColumn("merged_at")
FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at") FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at")
VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences") VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn} LastLoginAtColumn = postgres.TimestampzColumn("last_login_at")
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn} LastLoginIPColumn = postgres.StringColumn("last_login_ip")
DeletedAtColumn = postgres.TimestampzColumn("deleted_at")
DeletedDisplayNameColumn = postgres.StringColumn("deleted_display_name")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn, LastLoginAtColumn, LastLoginIPColumn, DeletedAtColumn, DeletedDisplayNameColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn} defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn}
) )
@@ -121,6 +129,10 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
MergedAt: MergedAtColumn, MergedAt: MergedAtColumn,
FlaggedHighRateAt: FlaggedHighRateAtColumn, FlaggedHighRateAt: FlaggedHighRateAtColumn,
VariantPreferences: VariantPreferencesColumn, VariantPreferences: VariantPreferencesColumn,
LastLoginAt: LastLoginAtColumn,
LastLoginIP: LastLoginIPColumn,
DeletedAt: DeletedAtColumn,
DeletedDisplayName: DeletedDisplayNameColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -17,15 +17,22 @@ type adCampaignsTable struct {
postgres.Table postgres.Table
// Columns // Columns
CampaignID postgres.ColumnString CampaignID postgres.ColumnString
Name postgres.ColumnString Name postgres.ColumnString
Weight postgres.ColumnInteger Weight postgres.ColumnInteger
IsDefault postgres.ColumnBool IsDefault postgres.ColumnBool
Enabled postgres.ColumnBool Enabled postgres.ColumnBool
StartsAt postgres.ColumnTimestampz StartsAt postgres.ColumnTimestampz
EndsAt postgres.ColumnTimestampz EndsAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz UpdatedAt postgres.ColumnTimestampz
OverrideBg postgres.ColumnString
OverrideFg postgres.ColumnString
OverrideLink postgres.ColumnString
OverrideBgDark postgres.ColumnString
OverrideFgDark postgres.ColumnString
OverrideLinkDark postgres.ColumnString
Urgent postgres.ColumnBool
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -67,33 +74,47 @@ func newAdCampaignsTable(schemaName, tableName, alias string) *AdCampaignsTable
func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable { func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable {
var ( var (
CampaignIDColumn = postgres.StringColumn("campaign_id") CampaignIDColumn = postgres.StringColumn("campaign_id")
NameColumn = postgres.StringColumn("name") NameColumn = postgres.StringColumn("name")
WeightColumn = postgres.IntegerColumn("weight") WeightColumn = postgres.IntegerColumn("weight")
IsDefaultColumn = postgres.BoolColumn("is_default") IsDefaultColumn = postgres.BoolColumn("is_default")
EnabledColumn = postgres.BoolColumn("enabled") EnabledColumn = postgres.BoolColumn("enabled")
StartsAtColumn = postgres.TimestampzColumn("starts_at") StartsAtColumn = postgres.TimestampzColumn("starts_at")
EndsAtColumn = postgres.TimestampzColumn("ends_at") EndsAtColumn = postgres.TimestampzColumn("ends_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn} OverrideBgColumn = postgres.StringColumn("override_bg")
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn} OverrideFgColumn = postgres.StringColumn("override_fg")
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn} OverrideLinkColumn = postgres.StringColumn("override_link")
OverrideBgDarkColumn = postgres.StringColumn("override_bg_dark")
OverrideFgDarkColumn = postgres.StringColumn("override_fg_dark")
OverrideLinkDarkColumn = postgres.StringColumn("override_link_dark")
UrgentColumn = postgres.BoolColumn("urgent")
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn, UrgentColumn}
) )
return adCampaignsTable{ return adCampaignsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns //Columns
CampaignID: CampaignIDColumn, CampaignID: CampaignIDColumn,
Name: NameColumn, Name: NameColumn,
Weight: WeightColumn, Weight: WeightColumn,
IsDefault: IsDefaultColumn, IsDefault: IsDefaultColumn,
Enabled: EnabledColumn, Enabled: EnabledColumn,
StartsAt: StartsAtColumn, StartsAt: StartsAtColumn,
EndsAt: EndsAtColumn, EndsAt: EndsAtColumn,
CreatedAt: CreatedAtColumn, CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn, UpdatedAt: UpdatedAtColumn,
OverrideBg: OverrideBgColumn,
OverrideFg: OverrideFgColumn,
OverrideLink: OverrideLinkColumn,
OverrideBgDark: OverrideBgDarkColumn,
OverrideFgDark: OverrideFgDarkColumn,
OverrideLinkDark: OverrideLinkDarkColumn,
Urgent: UrgentColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -25,6 +25,8 @@ type emailConfirmationsTable struct {
Attempts postgres.ColumnInteger Attempts postgres.ColumnInteger
ConsumedAt postgres.ColumnTimestampz ConsumedAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz CreatedAt postgres.ColumnTimestampz
Purpose postgres.ColumnString
LinkTokenHash postgres.ColumnString
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -74,9 +76,11 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
AttemptsColumn = postgres.IntegerColumn("attempts") AttemptsColumn = postgres.IntegerColumn("attempts")
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at") ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn} PurposeColumn = postgres.StringColumn("purpose")
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn} LinkTokenHashColumn = postgres.StringColumn("link_token_hash")
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn} allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn, PurposeColumn, LinkTokenHashColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn, PurposeColumn, LinkTokenHashColumn}
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn, PurposeColumn}
) )
return emailConfirmationsTable{ return emailConfirmationsTable{
@@ -91,6 +95,8 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
Attempts: AttemptsColumn, Attempts: AttemptsColumn,
ConsumedAt: ConsumedAtColumn, ConsumedAt: ConsumedAtColumn,
CreatedAt: CreatedAtColumn, CreatedAt: CreatedAtColumn,
Purpose: PurposeColumn,
LinkTokenHash: LinkTokenHashColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -0,0 +1,99 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var RetainedIdentities = newRetainedIdentitiesTable("backend", "retained_identities", "")
type retainedIdentitiesTable struct {
postgres.Table
// Columns
RetainedID postgres.ColumnString
AccountID postgres.ColumnString
Kind postgres.ColumnString
ExternalID postgres.ColumnString
Confirmed postgres.ColumnBool
LinkedAt postgres.ColumnTimestampz
DetachedAt postgres.ColumnTimestampz
Reason postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type RetainedIdentitiesTable struct {
retainedIdentitiesTable
EXCLUDED retainedIdentitiesTable
}
// AS creates new RetainedIdentitiesTable with assigned alias
func (a RetainedIdentitiesTable) AS(alias string) *RetainedIdentitiesTable {
return newRetainedIdentitiesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new RetainedIdentitiesTable with assigned schema name
func (a RetainedIdentitiesTable) FromSchema(schemaName string) *RetainedIdentitiesTable {
return newRetainedIdentitiesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new RetainedIdentitiesTable with assigned table prefix
func (a RetainedIdentitiesTable) WithPrefix(prefix string) *RetainedIdentitiesTable {
return newRetainedIdentitiesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new RetainedIdentitiesTable with assigned table suffix
func (a RetainedIdentitiesTable) WithSuffix(suffix string) *RetainedIdentitiesTable {
return newRetainedIdentitiesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newRetainedIdentitiesTable(schemaName, tableName, alias string) *RetainedIdentitiesTable {
return &RetainedIdentitiesTable{
retainedIdentitiesTable: newRetainedIdentitiesTableImpl(schemaName, tableName, alias),
EXCLUDED: newRetainedIdentitiesTableImpl("", "excluded", ""),
}
}
func newRetainedIdentitiesTableImpl(schemaName, tableName, alias string) retainedIdentitiesTable {
var (
RetainedIDColumn = postgres.StringColumn("retained_id")
AccountIDColumn = postgres.StringColumn("account_id")
KindColumn = postgres.StringColumn("kind")
ExternalIDColumn = postgres.StringColumn("external_id")
ConfirmedColumn = postgres.BoolColumn("confirmed")
LinkedAtColumn = postgres.TimestampzColumn("linked_at")
DetachedAtColumn = postgres.TimestampzColumn("detached_at")
ReasonColumn = postgres.StringColumn("reason")
allColumns = postgres.ColumnList{RetainedIDColumn, AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, KindColumn, ExternalIDColumn, ConfirmedColumn, LinkedAtColumn, DetachedAtColumn, ReasonColumn}
defaultColumns = postgres.ColumnList{ConfirmedColumn, DetachedAtColumn}
)
return retainedIdentitiesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
RetainedID: RetainedIDColumn,
AccountID: AccountIDColumn,
Kind: KindColumn,
ExternalID: ExternalIDColumn,
Confirmed: ConfirmedColumn,
LinkedAt: LinkedAtColumn,
DetachedAt: DetachedAtColumn,
Reason: ReasonColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -34,6 +34,7 @@ func UseSchema(schema string) {
GameSetupDraws = GameSetupDraws.FromSchema(schema) GameSetupDraws = GameSetupDraws.FromSchema(schema)
Games = Games.FromSchema(schema) Games = Games.FromSchema(schema)
Identities = Identities.FromSchema(schema) Identities = Identities.FromSchema(schema)
RetainedIdentities = RetainedIdentities.FromSchema(schema)
Sessions = Sessions.FromSchema(schema) Sessions = Sessions.FromSchema(schema)
SuspensionReasons = SuspensionReasons.FromSchema(schema) SuspensionReasons = SuspensionReasons.FromSchema(schema)
} }
@@ -0,0 +1,64 @@
-- Replace the default (house) ad campaign's single seed tip with the curated,
-- language-agnostic Scrabble tip set (one bilingual row per tip; the client picks the
-- column for the viewer's language). Data-only — the ad_messages schema is unchanged, so
-- a backend image rollback stays DB-safe. The default campaign is the fixed house id seeded
-- in 00001; ON DELETE CASCADE is irrelevant here (we only touch its messages).
-- +goose Up
DELETE FROM backend.ad_messages WHERE campaign_id = '00000000-0000-0000-0000-0000000000ad';
INSERT INTO backend.ad_messages (message_id, campaign_id, "position", body_en, body_ru) VALUES
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 0, 'Keep a balanced rack — a slight edge of consonants over vowels.', 'Держи на руках баланс — с лёгким перевесом согласных над гласными.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 1, 'Your "leave" (the tiles you keep) sets up your next turn — value it.', '«Остаток» (что оставляешь на руках) готовит следующий ход — цени его.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 2, 'Shed duplicate tiles — repeats clog your options.', 'Сбрасывай дубли фишек — повторы забивают возможности.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 3, 'A slightly consonant-heavy rack builds full-rack plays more easily.', 'Лёгкий перевес согласных проще складывается в выкладку всех фишек.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 4, 'Play several tiles per turn to keep your rack cycling.', 'Выкладывай по нескольку фишек за ход, чтобы рука обновлялась.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 5, 'Don''t hoard hard-to-place duplicates or a lone high-value tile.', 'Не копи труднопристраиваемые дубли или одинокую дорогую фишку.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 6, 'Using all your rack tiles in one move scores a large bonus — chase it.', 'Выкладка всех фишек с рук за ход даёт крупный бонус — стремись к ней.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 7, 'Learn common prefixes and suffixes — they extend words to use every tile.', 'Учи частые приставки и суффиксы — они растягивают слово на все фишки.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 8, '"Fish": play few tiles to keep a near-complete rack when you''re ahead.', '«Рыбачь»: сыграй мало фишек, сохранив почти всю руку, когда ведёшь.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 9, 'Don''t hoard high-value tiles — play them in good time, not at the very end.', 'Не копи дорогие фишки — играй их вовремя, а не под самый конец.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 10, 'Don''t hold a high-value tile waiting for a rare partner — usually a loss.', 'Не держи дорогую фишку ради редкого партнёра — обычно это проигрыш.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 11, 'Land your priciest tile on a premium square for a big single score.', 'Сажай самую дорогую фишку на бонусную клетку ради крупных очков.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 12, 'High-value tiles shine in parallel plays through short words.', 'Дорогие фишки сильны в параллельных выкладках через короткие слова.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 13, 'Stuck with an unplayable high-value tile late? Exchange it.', 'Завис с неиграбельной дорогой фишкой под конец? Обменяй её.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 14, 'The blanks are the most valuable tiles in the bag — guard them.', 'Пустышки — самые ценные фишки в мешке; береги их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 15, 'Save a blank for a full-rack play or a key premium square.', 'Береги пустышку для выкладки всех фишек или важной бонусной клетки.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 16, 'Don''t spend a blank cheaply — hold it for a much bigger gain.', 'Не трать пустышку по мелочи — придержи ради куда большей выгоды.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 17, 'Put high-value tiles on letter-bonus or word-bonus squares.', 'Клади дорогие фишки на бонус буквы или слова.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 18, 'Stack bonuses — a letter bonus under a word bonus multiplies both.', 'Совмещай бонусы — бонус буквы под бонусом слова умножает оба.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 19, 'Parallel plays can earn nearly half your points — look for them.', 'Параллельные выкладки могут давать почти половину очков — ищи их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 20, 'A hook adds one tile to an existing word to make a new one.', '«Крючок» — одна фишка к готовому слову, образующая новое.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 21, 'Hooks work at the front or the back of a word.', 'Крючки работают спереди и сзади слова.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 22, 'Short words are the keys to tight parallel plays — memorize them.', 'Короткие слова — ключ к плотным параллелям; выучи их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 23, 'Your opening word crosses the centre — keep it compact, don''t open up.', 'Первое слово идёт через центр — держи компактным, не раскрывайся.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 24, 'It''s not only your score — limit your opponent''s options too.', 'Это не только твои очки — ограничивай и возможности соперника.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 25, 'Denying a big reply often beats squeezing a few more points yourself.', 'Закрыть крупный ответ часто важнее, чем добрать пару своих очков.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 26, 'When ahead, keep the board tight and closed; avoid open lanes.', 'Ведёшь — держи доску плотной и закрытой, не открывай линии.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 27, 'When behind, open the board up to create high-scoring chances.', 'Отстаёшь — раскрывай доску ради шансов на крупный ход.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 28, 'Don''t leave a word-bonus square open right beside your word.', 'Не оставляй клетку бонуса слова открытой рядом со своим словом.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 29, 'Block a hot square even with a weak word to deny a big play.', 'Закрывай опасную клетку даже слабым словом, чтобы срубить крупный ход.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 30, 'Know words that take no hooks — use them to seal off lines.', 'Знай слова, не берущие крючков — ими запирай линии.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 31, 'Track the tiles played to judge what is still left in the bag.', 'Считай сыгранные фишки — так поймёшь, что осталось в мешке.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 32, 'Exchange when your rack is unbalanced or can only score low.', 'Меняй фишки, когда рука несбалансированна или тянет мало.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 33, 'A good exchange beats a bad play — a clean rack is worth a turn.', 'Хороший обмен лучше плохого хода — чистая рука стоит хода.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 34, 'Swap away a surplus of vowels or consonants to rebalance.', 'Сбрасывай в обмен избыток гласных или согласных, чтобы выровняться.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 35, 'Rare high-value tiles are gone once seen — note them as they appear.', 'Редкие дорогие фишки исчезают, едва мелькнув — отмечай их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 36, 'Once the bag is empty, deduce your opponent''s remaining tiles.', 'Когда мешок пуст, вычисли оставшиеся фишки соперника.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 37, 'Shed high-value tiles before the bag empties — don''t get stuck with them.', 'Сбрось дорогие фишки до опустения мешка — не зависай с ними.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 38, 'Unplayed tiles count against you at the end — try to go out first.', 'Несыгранные фишки минусуют очки в конце — старайся выйти первым.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 39, 'Going out first adds your opponent''s leftover tiles to your score.', 'Кто вышел первым, добирает очки за оставшиеся фишки соперника.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 40, 'Sometimes leaving one tile in the bag buys you an extra turn.', 'Иногда оставить одну фишку в мешке — это лишний ход.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 41, 'In the endgame, block the exact squares your opponent needs.', 'В эндшпиле блокируй именно те клетки, что нужны сопернику.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 42, 'Shuffle your rack to spot new patterns.', 'Перемешивай фишки на руках — так замечаешь новые сочетания.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 43, 'Separate prefix, suffix and middle tiles to anagram faster.', 'Разнеси приставку, суффикс и середину — анаграммы решаются быстрее.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 44, 'Value board position and future turns over raw points this turn.', 'Цени позицию и будущие ходы выше сиюминутных очков.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 45, 'Early game build position; midgame maximize score; endgame defend.', 'В начале — позиция, в середине — очки, в конце — защита.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 46, 'Learn the short-word lists first — they pay off in every game.', 'Сначала учи списки коротких слов — окупаются в каждой партии.');
-- +goose Down
-- Restore the original single house tip seeded by the baseline.
DELETE FROM backend.ad_messages WHERE campaign_id = '00000000-0000-0000-0000-0000000000ad';
INSERT INTO backend.ad_messages (message_id, campaign_id, "position", body_en, body_ru)
VALUES ('00000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000ad', 0,
'Tip: a play using all 7 tiles earns a +50 bonus.',
'Совет: ход всеми 7 фишками приносит бонус +50 очков.');
@@ -0,0 +1,10 @@
-- Capture the client app version (the build a report was sent from) with each feedback
-- message, so the operator console can show which version a player was on. Nullable, so the
-- rows that predate this keep working — additive and backward-compatible, so a backend image
-- rollback stays DB-safe (older code simply ignores the column).
-- +goose Up
ALTER TABLE backend.feedback_messages ADD COLUMN app_version text;
-- +goose Down
ALTER TABLE backend.feedback_messages DROP COLUMN app_version;
@@ -0,0 +1,11 @@
-- Capture the client's detected UTC offset ("±HH:MM") with each feedback message, so the
-- operator console can show the filed time in the sender's browser-local zone even before that
-- player has ever saved a profile (the account zone defaults to UTC until then). Nullable, so the
-- rows that predate this keep working — additive and backward-compatible, so a backend image
-- rollback stays DB-safe (older code simply ignores the column).
-- +goose Up
ALTER TABLE backend.feedback_messages ADD COLUMN browser_tz text;
-- +goose Down
ALTER TABLE backend.feedback_messages DROP COLUMN browser_tz;
@@ -0,0 +1,13 @@
-- Admit the 'vk' platform identity (VK Mini App users) into the identities.kind check
-- constraint, alongside telegram/email/robot. Expand-contract: widening the allowed set
-- is backward-compatible, so a backend image rollback stays DB-safe — the older code
-- simply never writes a 'vk' row. The table shape is unchanged (only the CHECK), so the
-- generated go-jet model is not regenerated.
-- +goose Up
ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk;
ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'vk'::text, 'email'::text, 'robot'::text])));
-- +goose Down
ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk;
ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'email'::text, 'robot'::text])));
@@ -0,0 +1,27 @@
-- Add the confirm deeplink token and the confirmation purpose to email_confirmations.
-- purpose tags what verifying the code/token does (email login, identity link, email
-- change, or account deletion); link_token_hash holds the SHA-256 hex of the one-click
-- deeplink token (only the hash is stored, mirroring the session-token model).
-- Expand-contract: both columns are additive. purpose gets a NOT NULL DEFAULT so
-- existing rows fill in; link_token_hash is nullable with a partial-unique index. A
-- backend image rollback stays DB-safe — older code simply ignores the new columns. The
-- table shape changes, so the generated go-jet model for this table is regenerated.
-- +goose Up
ALTER TABLE backend.email_confirmations
ADD COLUMN purpose text NOT NULL DEFAULT 'link',
ADD COLUMN link_token_hash text;
ALTER TABLE backend.email_confirmations
ADD CONSTRAINT email_confirmations_purpose_chk
CHECK ((purpose = ANY (ARRAY['login'::text, 'link'::text, 'change'::text, 'delete'::text])));
CREATE UNIQUE INDEX email_confirmations_link_token_hash_key
ON backend.email_confirmations (link_token_hash)
WHERE link_token_hash IS NOT NULL;
-- +goose Down
DROP INDEX IF EXISTS backend.email_confirmations_link_token_hash_key;
ALTER TABLE backend.email_confirmations
DROP CONSTRAINT IF EXISTS email_confirmations_purpose_chk;
ALTER TABLE backend.email_confirmations
DROP COLUMN IF EXISTS link_token_hash,
DROP COLUMN IF EXISTS purpose;
@@ -0,0 +1,47 @@
-- Account deletion as legal retention (not erasure). Two additive pieces.
--
-- retained_identities is an append-only journal of every credential detached from an
-- account — on unlink, email change, or account deletion (reason). It preserves the legal
-- dossier (which email/vk/tg was linked, and when) while the live identities row is
-- removed, so the (kind, external_id) frees for a new account to reuse. No unique
-- constraint and no kind CHECK: the same credential may recur across accounts and events,
-- and the log stays robust to future identity kinds. detached_at drives the retention TTL.
--
-- accounts gains: last_login_at / last_login_ip (stamped on a cold app-load, throttled),
-- deleted_at (the tombstone marker), and deleted_display_name (the real name retained for
-- the admin dossier after the live display_name is scrubbed to the anonymised label).
--
-- Expand-contract: everything is additive (a new table plus nullable columns), so a
-- backend image rollback stays DB-safe — older code simply ignores them. The accounts
-- table shape changes, so its generated go-jet model is regenerated.
-- +goose Up
CREATE TABLE backend.retained_identities (
retained_id uuid NOT NULL,
account_id uuid NOT NULL,
kind text NOT NULL,
external_id text NOT NULL,
confirmed boolean DEFAULT false NOT NULL,
linked_at timestamp with time zone NOT NULL,
detached_at timestamp with time zone DEFAULT now() NOT NULL,
reason text NOT NULL,
CONSTRAINT retained_identities_pkey PRIMARY KEY (retained_id),
CONSTRAINT retained_identities_reason_chk
CHECK ((reason = ANY (ARRAY['unlink'::text, 'change'::text, 'delete'::text])))
);
CREATE INDEX retained_identities_account_id_idx ON backend.retained_identities (account_id);
CREATE INDEX retained_identities_detached_at_idx ON backend.retained_identities (detached_at);
ALTER TABLE backend.accounts
ADD COLUMN last_login_at timestamp with time zone,
ADD COLUMN last_login_ip text,
ADD COLUMN deleted_at timestamp with time zone,
ADD COLUMN deleted_display_name text;
-- +goose Down
ALTER TABLE backend.accounts
DROP COLUMN last_login_at,
DROP COLUMN last_login_ip,
DROP COLUMN deleted_at,
DROP COLUMN deleted_display_name;
DROP TABLE backend.retained_identities;

Some files were not shown because too many files have changed in this diff Show More