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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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).
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.
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).
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.
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.
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.
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.
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).
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).
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.
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.
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.
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).
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.
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).
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.
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.
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.
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.
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
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).