Commit Graph

68 Commits

Author SHA1 Message Date
Ilia Denisov a067a2fd01 chore(android): de-anchor ANDROID_PLAN.md from the docs, re-enable the APK workflow
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Reword every code comment and doc that referenced ANDROID_PLAN.md or its stage anchors (§E, G-step-0, O1) so the living docs no longer depend on the plan file: .claude/CLAUDE.md, deploy/README.md, docs/ARCHITECTURE.md, docs/TESTING.md, ui/README.md, ui/android/.gitignore, ui/e2e/native.spec.ts, ui/src/lib/netstate.ts.

Re-enable the manual signed-APK workflow (android-build.yaml.disabled -> android-build.yaml; the CI host already has the Android SDK; workflow_dispatch-only, so it does not auto-run on this PR). Add docs/ICONS.md — the single-master icon/logo format reference (web, PWA, Android, future iOS, store listings).
2026-07-13 14:05:43 +02:00
Ilia Denisov de5ab9186c feat(legal): bilingual (RU + EN) legal pages with a language + theme switcher
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Add English versions of the privacy policy, EULA and offer (ui/legal/*_en.md) and render each legal page as one self-contained bilingual document: both language bodies plus a header language toggle and theme toggle (no back), a small inline ES5 script that switches language client-side (no reload, default Russian + persisted) and applies the theme (system default + persisted override) — mirroring the landing. The offer's English view transliterates the Russian product names spliced from the live catalog.

renderLegalHtml now takes both language sources; renderOffer splices the price list into both; the renderer bakes and reads the _en sources and pre-renders the static pages at boot. Also wrap the /offer/ contour probe in the same retry+timeout as the legal probe (the offer page is now bilingual and larger). Docs + renderer/ui tests updated.
2026-07-13 13:22:56 +02:00
Ilia Denisov 1d77ee83b3 fix(ci): make the legal-page probe size-independent
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
The /eula/ probe fetched the full ~129 KB page. While the monitoring stack boots after the rolling deploy and the CPU-capped (1 CPU / 192 MB) renderer is starved, that transfer exceeded the wget timeout for the entire 60s retry window, while the 3x-smaller /privacy/ passed — pre-rendering the pages did not help because the bottleneck is the transfer, not the render. Fetch only the <head> (head -c 4096) and assert the page's canonical URL, which uniquely identifies the rendered legal doc reaching the edge (the landing's canonical is erudit-game.ru/); the check now transfers ~4 KB regardless of page size.
2026-07-13 12:45:38 +02:00
Ilia Denisov 807edff327 fix(ci): retry the /privacy/ and /eula/ contour probe with a timeout
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 2m31s
The single-shot probe raced the render sidecar during the rolling deploy: across two attempts a different route flaked each time (/privacy/, then /eula/) against an otherwise-healthy renderer, while the contour served all three pages correctly — a deploy-time transient (a slow first render of the large EULA while every container recreates, or a connection blip). Wrap the check in the same 20x3s retry the landing/gateway/backend probe uses, with a 10s per-attempt wget timeout, so it waits the transient out instead of failing the deploy.
2026-07-13 12:25:30 +02:00
Ilia Denisov 0f3b4dbcff feat(legal): host the privacy policy and EULA at /privacy/ and /eula/
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 40s
Author ui/legal/{privacy,eula}_ru.md, reworked from the source documents: the seller INN is unified (290210610742), contacts unified to the Telegram bot + email + postal address, the EULA governing-law clause leads with Russian law + jurisdiction as residence tiers, the single-binding-language clause is dropped, data collection is scoped to voluntary/support provision, and "Компания" is no longer shout-cased.

Serve both as static pages through the render sidecar, reusing a shared renderLegalHtml generalised from renderOfferHtml: new GET /privacy/ and GET /eula/ (301 from the slashless form), the markdown baked into the image, no backend fetch. Route them at the edge via the @legal caddy matcher with a CI probe asserting each page's canonical URL + the seller INN, so a missing route cannot silently fall through to the landing. Add the two links to the landing footer.

Docs baked in: ARCHITECTURE (renderer legal pages + edge routing), FUNCTIONAL (+_ru) footer, renderer/README routes. Tests: renderer legal.test.mjs, ui renderLegalHtml unit, landing footer e2e.
2026-07-13 12:08:27 +02:00
Ilia Denisov 2d1fadb50c feat(offline): implicit net-state model, two-tier version gate, unified lobby
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 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs.

Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.

Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.

Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
2026-07-13 02:50:41 +02:00
Ilia Denisov ca2c6487cf feat(android): manual signed-APK CI workflow
Add .gitea/workflows/android-build.yaml (workflow_dispatch + confirm=build,
master-only; mirrors prod-deploy): builds the native SPA, bundles the offline
dicts, assembles a release APK as a run artifact. JDK 21 via setup-java; the
Android SDK is host-provisioned (host-executor runner) with a fail-fast verify
that also checks the runner user's access. Signing degrades gracefully — no
keystore => unsigned release APK, not a failure.

Wire ui/android/app/build.gradle: versionCode/versionName from the release tag
(-P props; '=' assignment, not the command form that binds .toInteger() to the
DSL setter's null return), plus a guarded signingConfigs.release from env.

Rename VITE_STORE_URL -> VITE_RUSTORE_URL (empty until publish).

Bake the E as-built into ANDROID_PLAN.md.
2026-07-12 20:27:07 +02:00
Ilia Denisov 4ba9da6721 feat(edge): community IP blocklist (Spamhaus DROP) at the gateway
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
Refuse a client whose IP is in a curated CIDR feed with 403 in the same
abuseGuard, before the fail2ban ban. Prod-only (keys by real client IP), off by
default.

- ratelimit.Blocklist: a sorted-range IPv4 matcher (binary search) with an
  allowlist checked first; ParseDROP reads the feed; ApplyRefresh keeps the
  last-good feed on a transient fetch failure and drops it fail-open once stale
  (better to under-block than block a legitimate client on a frozen feed). A
  separate static CIDR set, not the per-IP fail2ban store.
- gateway: a refresher goroutine re-fetches every few hours (bounded fetch + size
  cap); config GATEWAY_BLOCKLIST_{ENABLED,URL,ALLOW,REFRESH,MAX_STALENESS}.
  IPv6 is not matched (a v6 client is still covered by fail2ban / the honeypot).
- observability: gateway_blocklist_blocked_total + entries/age gauges; a Grafana
  alert warns before the feed is dropped; service-overview panels.
- deploy: compose env + write-prod-env.sh + prod-deploy/rollback wiring (opt-in
  via PROD_ vars).
- docs (ARCHITECTURE, deploy/README); unit tests (match, allowlist, IPv6 skip,
  parser, fault-tolerance) + a 403 abuse-guard integration test.
2026-07-11 13:33:28 +02:00
Ilia Denisov b6c2598710 feat(offer): live catalog price list in the public offer, served by the render sidecar
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 16m19s
Robokassa moderation requires the public offer to list every digital good with
its price. Move /offer/ off the static landing container to the render sidecar:
it splices the live catalog price list (§4.4) into the owner-edited
ui/legal/offer_ru.md and renders it with the shared ui/src/lib/offer.ts — one
renderer, no drift, always matching the current catalog with no redeploy.

- backend: /api/v1/internal/offer/pricing (internal, off the edge allow-list)
  projects the active catalog into two markdown tables — chip packs priced per
  rail (roubles / VK votes / Telegram Stars) and chip-priced values — through
  payments.Money so no float reaches the page. Cached in memory: warmed at boot,
  marked stale on every catalog mutation, so a served render issues no query.
- renderer: GET /offer/ fetches the tables and substitutes them at the
  <#pricing_template#> marker, then renders; offer_ru.md is baked into the image
  and marked is bundled from ui. GET /offer -> 301. Only /offer/ is edge-exposed.
- caddy: route /offer/ to the sidecar; drop the now-dead landing /offer/
  handlers and the vite emit-offer plugin.
- offer: fill §4.3 (the chip-payment wording) and drop the in-page back link.
- landing footer: a feedback link (the offer's Telegram contact) beside the
  offer link.
- docs (ARCHITECTURE, FUNCTIONAL +_ru, renderer README), CI /offer/ probe,
  unit + integration + node tests.
2026-07-10 20:25:41 +02:00
Ilia Denisov 0a0a9e5a8d fix(deploy): wire the Robokassa direct rail into the prod deploy + rollback
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 27s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The Robokassa credentials reached only the test contour (ci.yaml → TEST_
secrets). The prod-deploy / prod-rollback workflows and write-prod-env.sh
never rendered BACKEND_ROBOKASSA_*, so on prod the shop login was empty and
the direct RUB rail stayed disabled — the PROD_BACKEND_ROBOKASSA_* secrets
went nowhere.

Export the shop login + Password1/Password2 (secrets) and a
BACKEND_ROBOKASSA_TEST flag (a variable, so go-live is a flag flip not a
secret rotation) from both prod workflows, and emit the four ROBOKASSA_*
vars from write-prod-env.sh (the shared deploy/rollback env renderer) so a
rollback keeps the rail up. The compose already maps ROBOKASSA_* →
BACKEND_ROBOKASSA_*. Document the new secrets/variable in the deploy README
and .env.example. Password3 (Robokassa's JWT-invoice API) is unused.
2026-07-10 11:59:20 +02:00
Ilia Denisov dbd76d53e8 feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The first ads slice: a voluntary rewarded video credits chips. VK Mini App ads
(VKWebAppShowNativeAds) expose only a client-side watch result — no
server-to-server verify — so the credit is client-attested, guarded by a server
daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10).
The caps are both anti-abuse (bounding a forger who skips the ad and calls the
endpoint directly) and an economic conversion lever (limiting free chips so a
player who wants more buys). D29 amended to VK's reality.

Backend: CreditReward (VK-only, order-less, idempotent on a client nonce, floored
by the caps; payout from config rewarded_payout_chips, default 0 = off) + the
wallet.reward edge op returning the updated wallet (reward_chips gates the
"watch for chips" CTA). Additive migration (two config columns).

Client: the ads-network abstraction (lib/ads.ts, VK impl) + the VK bridge
(vkRewardedReady / vkShowRewarded) + the Wallet CTA + i18n. A contour test stub
(VITE_ADS_STUB -> a toast instead of a real ad; prod always real) and a temporary
diagnostic that logs the raw VK data, so we confirm on the contour exactly what
VK returns (harden to signature-verify if it carries one).

Tests: backend integration (credit, nonce idempotency, hourly cap, disabled,
non-VK refusal) + codec unit (reward wire). Docs: PAYMENTS(+ru) §10, D29 amend,
PLAN E6. Bundle: shared budget 30->31 (reward i18n strings).
2026-07-10 00:41:42 +02:00
Ilia Denisov 04435a3283 docs(payments): bake the E5 direct-rail decisions + a /pay/ CI probe
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m40s
Record the E5 delivery and resolved decisions in PLAN.md (Shp_order matching,
order-id idempotency, cabinet-side receipt, never-negative → schema-free) and
mark the stage WIP. Add a CI probe asserting /pay/robokassa/result reaches the
gateway rather than the landing catch-all.
2026-07-09 17:48:29 +02:00
Ilia Denisov be0e4995f3 feat(deploy): wire the Robokassa direct rail into the contour
Map the Robokassa merchant login + Password1/Password2 into the backend
container env from the deploy secrets (TEST_BACKEND_ROBOKASSA_*), and force
IsTest on the test contour so it can never take real money. An empty login
leaves the direct order + callback endpoints unregistered.
2026-07-09 17:45:54 +02:00
Ilia Denisov 625fc3135a feat(ui): serve the public offer at /offer/ with a landing footer link
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 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
The offer is the legal document a purchase accepts, needed before real-money
intake goes live. Convert the source PDF to an editable ui/legal/offer_ru.md and
render it to a standalone static dist/offer/index.html at build (a vite
emit-offer plugin using marked); the landing container serves it at /offer/,
with a bare /offer redirecting in. Add a small centered "Публичная оферта"
footer link on the landing (ru/en) and a CI probe asserting /offer/ serves the
page rather than silently falling through to the landing shell.
2026-07-09 16:03:50 +02:00
Ilia Denisov a58f2ce0e4 fix(deploy): support a non-standard S3 port for the pgBackRest endpoint
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 10s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m43s
The S3 endpoint is a host only; some providers publish a separate, non-443
port. Add an optional PGBACKREST_S3_PORT (default 443) alongside the endpoint
host, wired through the prod overlay, write-prod-env.sh and both prod workflows,
and clarify in the docs that the endpoint variable takes the host alone.
2026-07-09 01:14:05 +02:00
Ilia Denisov e922f5f3b9 feat(deploy): continuous WAL archiving to S3 for point-in-time recovery
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Add pgBackRest-based PITR for the production database, shipped disarmed
(archive_mode and the base-backup timer gated off) so it stays inert until the
operator arms it before real payments — an un-armed deploy cannot pile WAL onto
the disk.

- Postgres now builds from a thin image carrying pgBackRest (archive_command runs
  in-process); the prod overlay wires an encrypted (AES-256-CBC), path-style S3
  repository with 30-day retention and a daily full base-backup systemd timer.
- Repository config/secrets flow through the PROD_PGBACKREST_* Gitea set and
  write-prod-env.sh; prod-rollback re-renders the same env so a rollback cannot
  disarm archiving.
- Grafana alerts watch pg_stat_archiver (failing / stalled), absent-safe on the
  test contour, which never archives.
- Fix the pre-migration pg_dump to snapshot the whole database (was backend-only,
  silently excluding payments).
- Document the PITR runbook, the arming sequence and the restore drill in
  deploy/README.md; record the measured cost/perf assessment.
2026-07-09 00:58:04 +02:00
Ilia Denisov 4d1f389cf1 ci: skip the test-contour deploy on a no-code change
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 15s
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 deploy job gated only on the event/ref, so a docs-only PR into
development (where unit/integration/ui/conformance path-skip) still
redeployed the test contour for nothing. Gate it on the `changes` job
too: deploy only when the Go or UI side changed. `changes` still
defaults both true when the diff is uncomputable, and a workflow/deploy
edit forces both true, so ambiguous or infra changes still deploy.
2026-07-07 23:22:29 +02:00
Ilia Denisov e9f4cb0178 test(offline): mock e2e for a device-local vs_ai game (C8)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
A Playwright spec drives the whole offline flow in the mock build: force the
installed-PWA display mode, enter offline via the Settings toggle (its readiness check
fetches the dawgs), assert the blue chrome + online-games-hidden + Stats-disabled, then
create and play a device-local English vs_ai game with a pinned bag seed (deterministic
rack NEWYMAO) -- play the opening WAY across the centre, watch the robot reply with a
real move, and reload to confirm the IndexedDB replay.

Enabling infra (all e2e-only; nothing enters the production build):
- mock/client.ts fetchDict serves the per-variant dawgs from the preview build's
  /e2edict/ (was: threw 'unsupported in mock').
- scripts/e2e-dict.mjs copies the real dawgs into dist-e2e from E2E_DICT_DIR (the ui CI
  job fetches the scrabble-dictionary release like the Go jobs; local default: the
  sibling scrabble-solver/dawg); playwright.config runs it between build and preview.
- localgame/id.ts setForcedSeed + gateway.ts window.__mock.setLocalSeed: a mock-only
  seam to pin a local game's bag seed (tree-shaken from prod).
- ci.yaml ui job: fetch the dawgs + pass E2E_DICT_DIR to the e2e step.
- docs/TESTING.md: the offline e2e + the mock-dawg wiring.

Verified: check 0 / unit 482 / e2e 198 (both engines) / app entry 113.8/114.
2026-07-06 20:54:03 +02:00
Ilia Denisov a9c8f1ecfe test(offline): real-dictionary move-generator conformance in CI
CI / changes (pull_request) Successful in 1s
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 1m38s
Phase A (A4): prove the ported move generator (#188) against the FULL shipped
dictionaries, not just the tiny samples — the deep graphs and complete 26/33-letter
alphabets the samples cannot reach.

- backend/cmd/movegen: add a -dawg-dir mode that emits per-variant golden move-gen
  vectors from the real dawgs (a bounded first-move + a blank case + a deep 7-tile
  mid-game position). Regenerated in CI to /tmp, never committed (like the
  dictgen/validategen vectors), so no dictionary version is pinned into the repo.
- ui/src/lib/dict/generate.realparity.test.ts: env-gated (DICT_DAWG_DIR +
  DICT_MOVEGEN_DIR) parity against that golden — 9 positions across scrabble_en /
  scrabble_ru / erudit_ru match the Go solver exactly. Skips cleanly when unset.
- .gitea/workflows/ci.yaml: the conformance job now generates the movegen golden
  and points the gated vitest at it (DICT_MOVEGEN_DIR).
2026-07-06 02:16:50 +02: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
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
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
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
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
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 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 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 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
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
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 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 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
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
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
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
Ilia Denisov c59e522732 feat(deploy): visible prod-deploy jobs + manual prod-rollback
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
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 1m23s
- prod-deploy.yaml is now four visible sequential jobs (build -> deploy-main ->
  deploy-bot -> verify) so the rollout stages show in the Actions UI; the
  per-service rolling stays in the deploy-main log.
- prod-rollback.yaml: a separate manual workflow_dispatch. Leave target_version
  blank to roll back to the previous deployed version (the host now tracks
  DEPLOYED_TAG + PREVIOUS_TAG), or pick a release tag. Re-deploys an already
  published image rolling + health-gated, image-only (no rebuild, no DB migration).
- prod-deploy.sh tracks the previous tag (commit_tag) for the blank-input rollback.
- Docs: ARCHITECTURE §13 + deploy/README runbook cover versioning + rollback.
2026-06-22 07:37:08 +02:00
Ilia Denisov 171b71b7e0 feat(deploy): manual prod-deploy pipeline with rolling rollback (Stage 18)
A workflow_dispatch-only rollout from master (confirm=deploy):

- .gitea/workflows/prod-deploy.yaml builds + pushes the images to the registry,
  ships the compose/config/certs/env over SSH, deploys the main host via
  prod-deploy.sh, then the bot host, then verifies the public site.
- deploy/prod-deploy.sh rolls the main stack one service at a time in dependency
  order (postgres->backend->gateway->landing->validator->caddy), health-checking
  after each; any failure rolls the whole stack back to the previous tag. A schema
  migration adds a maintenance window: the backend (sole writer) is stopped for a
  consistent pg_dump before migrating; image rollback stays DB-safe (expand-contract),
  the dump is kept for a manual restore.
- prod overlay: pull the four main images from the registry by tag.
- Runtime secrets reach the host via a sourced env.sh (single-quoted values keep the
  bcrypt hash's literal $ intact, unlike a --env-file).
2026-06-22 00:25:09 +02:00
Ilia Denisov 2b399d0838 feat(deploy): prod compose split + host-memory monitoring (Stage 18)
Split the contour across the two prod hosts and retune for the small main host:

- Gate vpn+bot to the telegram-local profile. The CI test deploy now passes
  --profile telegram-local so the test contour still brings them; the prod main
  host omits both, and the prod bot runs standalone from docker-compose.bot.yml.
- docker-compose.prod.yml (main-host overlay): publish caddy 80/443 (no host
  caddy in prod; caddy owns ACME) and gateway 9443 (the remote bot dials in over
  mTLS); GOMAXPROCS=2, smaller memory caps and 7d Prometheus retention for the
  2 vCPU / 1.9 GiB host. It launches deliberately undersized; resize reactively.
- docker-compose.bot.yml: standalone bot for the tg host (no VPN, OTLP off since
  otelcol is unreachable from there, dials the main host's bot-link).
- Add node_exporter + a Prometheus scrape so host memory pressure (the OOM
  signal on the tight main host), not just per-container docker_stats, is visible.
- Soften AWG_CONF to a default: only the profiled vpn sidecar consumes it, and
  compose interpolates profiled-out services too, so prod must not require it.
2026-06-22 00:12:43 +02:00
Ilia Denisov e71e40eef5 feat(telegram): promo bot + channel-chat moderation gate
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
Add a second standalone promo bot to the bot container (answers /start with a
localized message + a URL button into the main bot's Mini App) and gate write
access in a channel's linked discussion chat: grant on join when the Telegram
user is registered and neither admin-suspended nor holding a new chat_muted
role, and revoke/grant on the matching moderation change for a member currently
in the chat.

Eligibility (registered AND NOT suspended AND NOT chat_muted; the game
suspension dominates) is resolved once in the backend and reached two ways: the
bot's join-time unary ResolveChatEligibility over the existing mTLS bot-link,
and a backend chat_access_changed event -> gateway -> ChatGate command
(idempotent; a temporary-block-expiry sweeper may over-emit). The bot guards the
block/unblock path with getChatMember, since bots cannot list members.

A web_app button cannot open another bot's Mini App (it signs initData with the
sending bot's token), so the promo button is a t.me ?startapp URL reusing the
UI's VITE_TELEGRAM_LINK. The bot must be a chat admin with the restrict-members
right and chat_member in its allowed updates.

No schema change: chat_muted reuses the data-driven account_roles table.
2026-06-21 14:46:51 +02:00
Ilia Denisov 6aeb529f13 feat(telegram): split connector into home validator + remote bot
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 53s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Failing after 2m6s
Move all Telegram egress off the main host. The single connector held the
bot token, long-polled Telegram and answered the gateway/backend over the
trusted internal network, so the whole component (including login validation)
shared fate with its VPN sidecar. Split it into two binaries that share the
token:

- cmd/validator (home, no VPN): Mini App initData + Login Widget HMAC only,
  never calls the Bot API. The gateway dials it for Telegram auth, so game
  login is now independent of Telegram reachability.
- cmd/bot (remote): Bot API long-poll + sendMessage, the only component
  reaching Telegram. It holds no inbound port — it dials the gateway over a
  new reverse mTLS bot-link (pkg/proto/botlink/v1) and executes the send
  commands the gateway pushes.

The gateway funnels sends to the bot-link: out-of-app push is fire-and-forget
(at-most-once, dropped if no bot is connected); the backend admin broadcasts
reach a gateway-served relay that forwards them and awaits the bot's ack
(SendToUser/SendToGameChannel contract preserved). mTLS (pkg/mtls) is the one
inter-service link that leaves the trusted segment; validator<->gateway and
the relay stay plaintext internal. The bot is Telegram-rate-limited.

One bot now; the gateway bot registry, an owns_updates flag and per-command
ids leave seams for N later. 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; bot-link certs from deploy/gen-certs.sh,
generated in CI). The prod wiring — the bot on a separate host (no VPN), the
gateway bot-link port published, PROD_ certs with scheduled rotation, an SSH
deploy of both hosts together — is the deferred final stage (PRERELEASE.md TX,
Stage 18).

Docs: ARCHITECTURE, PRERELEASE (phase TX), platform/telegram + gateway +
backend + deploy READMEs, FUNCTIONAL(+ru), CLAUDE.md, .env.example.
2026-06-21 00:19:07 +02:00
developer 9824214fd7 ci(deploy): probe backend /readyz so a dead backend fails the deploy (#94)
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 1m11s
The post-deploy probe checked only the static landing and the gateway-served SPA shell, so a crash-looping backend passed the deploy green. Add an http://backend:8080/readyz probe on the internal network (and dump backend logs on failure) so an unready backend fails the deploy loudly.
2026-06-20 19:04:52 +00:00
Ilia Denisov a5db10c46e feat(engine,deploy): seed-drift guard + track current dictionary release
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 1m12s
The dictionary release moved to v1.2.1 while DICT_VERSION stayed pinned at
v1.0.0 in CI and the image/compose seed defaults. Two problems:

1. CI validated against a stale dictionary.
2. The contour seed could be bumped on a live volume, which silently relabels
   the already-seeded bytes — voiding games pinned to the prior label and
   serving the wrong dictionary for new ones. The flat DAWGs carry no embedded
   version, so this drift was undetectable.

Changes:

- Seed-drift guard: OpenWithVersions records the flat dir's version in a
  .seed_version marker on first boot and refuses to start when a later
  BACKEND_DICT_VERSION disagrees. DICT_VERSION is now the seed for a *fresh*
  volume only; a live contour migrates through the admin console (old versions
  stay resident, in-progress games keep replaying).
- Track the current release: CI's DICT_VERSION centralised to one workflow-level
  env (v1.2.1); image/compose/.env seed defaults bumped to v1.2.1. The deploy
  job keeps reading the per-contour vars.TEST_DICT_VERSION.
- Docs: ARCHITECTURE §5 (decision record), backend/deploy READMEs, PRERELEASE
  tracker (DV row).

Verified locally against the v1.2.1 artifact: gofmt, build, vet, unit and
integration (-tags=integration) all green.
2026-06-20 19:26:32 +02:00
Ilia Denisov 57c778f9b2 feat(telegram,game): single bot + per-user variant preferences
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.

- Telegram: one bot; drop service_language and the supported_languages
  set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
  connector proto). The single bot renders chat and out-of-app push in
  the recipient's preferred_language; remove the game-language push
  routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
  {erudit_ru}, CHECK non-empty + subset of the three variants). Gates
  the New Game picker, vs-AI and the friend invitation the player
  creates, enforced server-side (HTTP 400 otherwise); an invited friend
  may still accept any variant. Edited on the Settings screen; variants
  are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
  and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
  VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
  to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
  backend, ui, UI_DESIGN, PRERELEASE).

The migration squash is deferred to a follow-up PR.
2026-06-20 14:23:25 +02:00
Ilia Denisov 03eb8044ff feat(ui): real friend-invite share with a per-bot link
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 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
The friend-code 'share' was an <a> that just opened the bot. Make it a real share:
Telegram's native share-to-chat picker inside the Mini App (openTelegramLink +
t.me/share/url), the system share sheet (navigator.share) on the web, else copy the
link. The shared deep link points at the same bot the player is in — it picks
VITE_TELEGRAM_LINK_EN/_RU by the session's service language, falling back to the
single VITE_TELEGRAM_LINK. Adds the per-bot build args across Dockerfile / compose /
ci.yaml / .env / docs; PLAN TODO-5 updated.
2026-06-15 15:49:36 +02:00
Ilia Denisov 8881214213 R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
2026-06-10 16:56:03 +02:00
Ilia Denisov f20a4b49ff R3: split the landing into its own static container
- gateway/Dockerfile gains a `landing` target: caddy:2-alpine + the shared
  Vite build (identical build args keep the ui stage a single cached build);
  the gateway target drops landing.html from the embed.
- The contour caddy routes /app/, /telegram/ and the Connect path to the
  gateway; the catch-all — the landing at / and any stray path — goes to the
  new landing service, so junk traffic is absorbed by static file serving.
- deploy/landing/Caddyfile mirrors the webui caching (immutable assets,
  no-cache shells) and falls back unknown paths to the landing shell.
- The gateway's / now 308-redirects to /app/ (keeps a local no-caddy run
  usable); webui placeholder landing.html removed.
- CI deploy probe checks both / (landing) and /app/ (gateway).

Verified: both images build; the landing container serves landing.html at /
(no-cache) with junk-path fallback; the gateway image redirects / to /app/
and carries no landing content.
2026-06-10 02:20:10 +02:00
Ilia Denisov aa137e3558 R2: load-test harness + contour resource observability
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 3s
New scrabble/loadtest module (the pre-release stress harness): seeds 1000 guest +
10000 durable accounts with pre-created sessions directly in Postgres (token hash
matches backend/internal/session), drives virtual players through the edge protocol
(real 2-4p games assembled via invitations, mid-ranked legal moves generated locally
by the embedded scrabble-solver — the edge carries no board, so the client replays
history), plus nudge/chat/check-word/draft/profile/stats and a gateway-hammer that
verifies the rate limiter. Prints a trip-report summary (per-op latency percentiles,
result codes, live-event tally). Go unit tests cover the pure pieces; the DAWG-backed
move test runs under BACKEND_DICT_DIR.

Contour: add cAdvisor + postgres_exporter + a 'Scrabble - Resources' Grafana
dashboard and the two Prometheus scrape jobs, for the R2/R7 stress-run resource
baseline.

CI: gate ./loadtest/... (path filter + vet/build/test). Docs: TESTING, ARCHITECTURE,
project CLAUDE repo layout.
2026-06-09 23:45:24 +02:00
Ilia Denisov 3fd279cf8c Landing v2: icon switchers, ephemeral theme, channel link, drop browser CTA
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 7s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
Owner review-pass rework of the landing page:
- Rename the per-language Telegram link build var
  VITE_TELEGRAM_LINK_EN/_RU -> VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU
  (it carries a channel username; the landing builds https://t.me/<name> --
  the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*).
- Language switcher -> a globe icon dropdown (flags + names), saved + synced
  to the app prefs.
- Theme switcher -> a sun/moon icon toggle, ephemeral (follows the system
  scheme, no auto, never persisted) -- galaxy-game style.
- Drop the "Play in browser" CTA (no standalone-web onboarding yet).

Docs: FUNCTIONAL(+ru), PLAN, deploy + ui READMEs.
2026-06-08 16:40:07 +02:00
Ilia Denisov e16076c89e Stage 17 round 6 (#16-20): landing page, /app/ move, cache + stream fixes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
Close out Stage 17 round 6:

- Landing page at / — one Vite build with two entries (index.html = game
  SPA, landing.html = a lightweight landing reusing the theme/i18n/
  aboutContent leaf modules, not the app store).
- Move the web game SPA to /app/; the Telegram Mini App stays at /telegram/
  (gateway webui.Handler(stripPrefix, indexName): landing at /, SPA at /app/
  + /telegram/). Per-language "Play in Telegram" link via new
  VITE_TELEGRAM_LINK_EN/_RU build vars (button hides when unset).
- Cache headers: hash-named /assets/* immutable, HTML shells no-cache (the
  go:embed zero modtime emitted no validators, so the client re-downloaded
  the whole bundle every launch).
- Live-stream 15s abort fix: an immediate heartbeat on open + a 10s default
  interval (the first tick at 15s raced the edge idle timeout -> reconnect
  storm).

PLAN/ARCHITECTURE(§13)/FUNCTIONAL(+ru)/gateway+ui+deploy READMEs updated;
round 6 closed. Tests: gateway webui/connectsrv units, ui landing unit + e2e,
full e2e (60) green.
2026-06-08 13:33:05 +02:00