feat(export): server-rendered artifacts behind one signed download URL
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) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s

The finished-game export now works identically on every platform: the
client mints a signed relative URL (game.export_url) carrying its date
locale, IANA time zone and localized non-play labels, resolves it
against its own origin and hands it to the platform's native download —
Telegram downloadFile, VKWebAppDownloadFile, or a plain browser anchor
(also the desktop VK iframe). Both artifacts ride the route: the .gcg
text (no more clipboard mode, except the legacy pre-8.0 Telegram
fallback) and the PNG of the final position.

The PNG is rasterized by a new internal 'renderer' sidecar (node:22-slim
+ skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the
SAME ui/src/lib/gameimage.ts the web project unit-tests, bundled at
image build time — one renderer, no drift; the browser no longer draws
or delivers bytes itself. The backend rebuilds the render payload from
the journal + engine.AlphabetTable, verifies the HMAC (10-minute TTL,
BACKEND_EXPORT_SIGN_KEY, constant-time, uniform 404s) on its public
group, and streams the artifact as a named attachment; the gateway
forwards /dl/* behind the per-IP public rate limiter (caddy @gateway
matcher extended — the landing catch-all trap).

Deploy: renderer service (compose + prod overlay + roll before backend
+ prod push list), EXPORT_SIGN_KEY env (TEST_/PROD_ secrets), CI runs
the sidecar smoke in the ui job. Docs: ARCHITECTURE export-delivery
section, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README.
This commit is contained in:
Ilia Denisov
2026-07-02 18:44:07 +02:00
parent 16a4431158
commit 3471d40576
60 changed files with 2216 additions and 236 deletions
+26 -5
View File
@@ -785,11 +785,32 @@ the same rows and is likewise self-contained — we ship our own writer (the sol
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
exchanges), plus `#note` lines for resignations and timeouts, which the standard
does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive`
otherwise), so an in-progress journal is never leaked mid-play; the client
shares the `.gcg` file via the Web Share API where available; an Android in-app WebView
(Telegram / VK) has no Web Share and silently ignores an `<a download>`, so there it copies the GCG
text to the clipboard instead (the payload is tiny), and a plain desktop browser downloads the file.
does not cover. **Export is offered only on a finished game** (`game.ErrGameActive`
otherwise), so an in-progress journal is never leaked mid-play.
**Export delivery — the signed download URL.** Both export artifacts — the `.gcg` text
and the rendered **PNG of the final position** — travel one uniform route on every
platform: the client calls the authenticated `game.export_url` op, the backend mints a
**relative, HMAC-signed, short-lived path**
(`/dl/{game}/{kind}?e=<expiry>&…&s=<HMAC-SHA256>`, 10-minute TTL,
`BACKEND_EXPORT_SIGN_KEY`), and the client resolves it against its **own origin** (no
service ever needs to know the public host) and hands it to the platform's native
download: Telegram `downloadFile` (Bot API 8.0), `VKWebAppDownloadFile`, or a plain
browser anchor elsewhere (including the desktop VK iframe). The GET is the gateway's
**only unauthenticated data route** (`/dl/*` — in the caddy `@gateway` matcher and the
per-IP public rate limiter): the native download calls carry no cookies or headers, so
the URL's signature — verified by the backend on its `/api/v1/public` group in constant
time, every failure a uniform 404 — is the whole grant, and minting requires an
authenticated caller on a finished game. The PNG is rasterized on demand by the
**`renderer` sidecar** (internal-only Node + skia-canvas running the same
`ui/src/lib/gameimage.ts` the web project unit-tests — one renderer, no drift;
`renderer/README.md`); the backend rebuilds the render payload from the journal +
`engine.AlphabetTable`, and the client's date locale plus its UI-localized non-play
labels ride the signed URL so the server render matches the player's presentation.
Nothing is stored: the artifact is re-derived from the immutable finished journal on
each GET. The only remaining platform branch is a legacy Telegram client predating
`downloadFile`, where the GCG falls back to the old clipboard copy and the image option
is not offered.
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
exchanges alphabet indices, but the persisted journal (and everything derived from it —
+22 -22
View File
@@ -326,29 +326,29 @@ journal; an AI game is throwaway). The format chooser is always the app's **own
modal** — deliberately not Telegram's native popup, whose callback runs without
user activation and silently breaks the share/clipboard delivery it leads to.
The **image** is rendered on the client (Canvas 2D, always the light theme): the
final board with classic coordinate axes A..O / 1..15 on the left — premium squares
as plain colour fills, no text labels — and a compact per-seat scoresheet on the
right: each seat's name and final score in the header (🏆 by the winner), then one
row per move carrying the main word's classic coordinate (an across play is
row-first, `8G`; a down play column-first, `H8` — the GCG convention), the word and
its points; extra words of a multi-word play ride a smaller second line, non-play
moves show as localized notes (pass/exchange/resign/timeout), and a closing ± row
shows the endgame rack settlement when there was one (the final scores are
authoritative — running totals alone do not include it). The footer stamps the site
host and the finish date in the device locale. The scoresheet typography is fixed;
a long game stretches the board (never below its minimum) so the image carries no
dead space.
The **image** is rendered on the server (the internal render sidecar runs the same
drawing module the web client tests; always the light theme): the final board with
classic coordinate axes A..O / 1..15 on the left — premium squares as plain colour
fills, no text labels — and a compact per-seat scoresheet on the right: each seat's
name and final score in the header (🏆 by the winner), then one row per move
carrying the main word's classic coordinate (an across play is row-first, `8G`; a
down play column-first, `H8` — the GCG convention), the word and its points; extra
words of a multi-word play ride a smaller second line, non-play moves show as
localized notes (pass/exchange/resign/timeout), and a closing ± row shows the
endgame rack settlement when there was one (the final scores are authoritative —
running totals alone do not include it). The footer stamps the site host and the
finish date in the device locale. The scoresheet typography is fixed; a long game
stretches the board (never below its minimum) so the image carries no dead space.
Delivery per format: the **GCG** file is Web-Shared where the platform supports it;
on an Android in-app client (Telegram / VK), which has neither Web Share nor a
working file download, it copies the GCG text to the clipboard (with a confirming
toast); otherwise it downloads the file. The **PNG** is Web-Shared or downloaded the
same way, but a binary image has no clipboard-text fallback at all — so the image
option is currently **withheld inside the in-app webviews** (Telegram / VK) and
offered on the plain web and mobile browsers only; it returns there with the
server-rendered signed-URL delivery (Telegram `downloadFile` /
`VKWebAppDownloadFile`). Statistics (durable accounts only):
Delivery is **one route for both formats on every platform**: the app requests a
signed, short-lived download link and hands it to the platform's native download —
Telegram's download dialog, VK's `VKWebAppDownloadFile`, or an ordinary browser
file download elsewhere (including the desktop VK iframe). The link needs no login
to fetch (the platforms' download calls carry none), is valid for minutes, and
serves the artifact as a named file attachment. The single exception is a legacy
Telegram client without the download dialog (pre-Bot API 8.0): there the GCG falls
back to the old clipboard copy (with the confirming toast) and the image option is
not offered. Statistics (durable accounts only):
wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus). It
also shows the player's **move count** (their plays — passes and exchanges do not
+11 -10
View File
@@ -335,7 +335,8 @@ Telegram.
сознательно не нативный попап Telegram: его коллбек приходит без user activation и
молча ломает доставку через share/буфер, к которой ведёт выбор.
**Картинка** рендерится на клиенте (Canvas 2D, всегда светлая тема): финальная
**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же
модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная
доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой
цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам
справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по
@@ -348,15 +349,15 @@ Telegram.
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
Доставка по форматам: файлом **GCG** клиент делится через Web Share там, где
платформа это поддерживает; в Android-приложении (Telegram / VK), где нет ни Web
Share, ни рабочей загрузки файла, копирует текст GCG в буфер обмена (с
подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так же,
но у бинарной картинки нет текстового фолбэка через буфер вовсе — поэтому пункт
«картинка» пока **скрыт во встроенных webview** (Telegram / VK) и предлагается
только в обычном вебе и мобильных браузерах; туда он вернётся с серверным рендером
и доставкой по подписанному URL (Telegram `downloadFile` /
`VKWebAppDownloadFile`). Статистика (только у постоянных аккаунтов):
Доставка **один путь для обоих форматов на любой платформе**: приложение
запрашивает подписанную короткоживущую ссылку на скачивание и передаёт её родному
механизму платформы — диалогу загрузки Telegram, `VKWebAppDownloadFile` у VK или
обычному браузерному скачиванию файла в остальных случаях (включая десктопный
iframe VK). Ссылка не требует логина при скачивании (родные загрузчики платформ его
не несут), живёт минуты и отдаёт артефакт именованным файлом. Единственное
исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там
GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
«картинка» не предлагается. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). Также
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
+9 -2
View File
@@ -19,8 +19,15 @@ tests or touching CI.
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation and the GCG share/copy/download choice, plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the GCG export's
finished-only visibility.
invitations section, the stats screen, profile editing, and the export chooser's
finished-only visibility + its signed-URL download flow (route-intercepted).
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
real self-played 35-move game) must rasterize to a plausible PNG
(`pnpm -C renderer test`). The drawing module's pure parts (scoresheet, layout,
notation) stay unit-tested in `ui/` — the sidecar test guards only the
skia/bundling seam. Pixel goldens are deliberately avoided (fonts differ across
hosts).
- **Local-eval conformance** — the client's on-device move preview (the ported
dawg reader + validator, `ui/src/lib/dict`) is checked byte-for-byte against the
authoritative Go engine. `backend/cmd/dictgen` and `backend/cmd/validategen` emit
+23 -22
View File
@@ -407,28 +407,29 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
(the word(s) and the move score, no running total); the 📤 in the history header appears
only once the game is finished — never in an honest-AI game (throwaway practice) — and
opens the **format chooser**: always the app's own modal, never Telegram's native popup
(a `showPopup` callback carries no user activation, which silently breaks the
`navigator.share` / clipboard delivery it leads to — on-device finding). The accent button
leads with the image where offered, the GCG file is the quiet alternative (GCG needs the
connection, the image renders locally). The GCG file goes by Web Share where available, a
clipboard copy in an Android in-app WebView (Telegram / VK, which has neither Web Share nor
a working download), else a Blob download. The image option shows **only outside the in-app
WebViews** for now (a binary PNG has no working route there — no Web Share, a dead
`<a download>`, a text-only clipboard, and the long-press menu mangles data: URLs); it
returns there with the server-rendered signed-URL delivery. Confirming a resign reveals
the full board: it closes the history drawer (portrait) and zooms the board out.
- **Export image** (`lib/gameimage.ts`, Canvas 2D, dynamically imported): always the light
palette (pinned constants mirroring `app.css`), the final board with classic axes A..O /
1..15, premium squares as plain colour fills (no text labels), tiles in the in-game style
(bevel, letter top-left, value bottom-right, ✻ for an Erudit blank), no last-move
highlight. The right column is the scoresheet: per-seat name over the final score (🏆 by
the winner), one fixed-typography row per move — muted classic coordinate (across =
row-first `8G`, down = column-first `H8`), the main word, points right-aligned; extra
words of a multi-word play on a smaller second line; italic localized notes for
pass/exchange/resign/timeout; a closing muted ± row for the endgame rack settlement when
present. Footer: `hostname · finish date` in the **device** locale. The scoresheet's
height drives the board side (never below its minimum): a long game stretches the board,
never the typography, so the image has no dead space.
(a `showPopup` callback carries no user activation, which silently breaks gesture-gated
delivery APIs — on-device finding). The accent button leads with the image, the GCG file
is the quiet alternative; both need the connection. Both formats travel the same
signed-URL route: the app mints a relative link (`game.export_url`), resolves it against
its own origin and hands it to the platform's native download — Telegram `downloadFile`,
`VKWebAppDownloadFile`, else a plain anchor download (also the desktop VK iframe, where
the bridge method is absent and the anchor works). A legacy Telegram client without
`downloadFile` keeps the old GCG clipboard copy and hides the image option. Confirming a
resign reveals the full board: it closes the history drawer (portrait) and zooms the
board out.
- **Export image** (`lib/gameimage.ts` — the shared drawing module the render sidecar
executes; the browser app no longer draws it): always the light palette (pinned constants
mirroring `app.css`), the final board with classic axes A..O / 1..15, premium squares as
plain colour fills (no text labels), tiles in the in-game style (bevel, letter top-left,
value bottom-right, ✻ for an Erudit blank), no last-move highlight. The right column is
the scoresheet: per-seat name over the final score (🏆 by the winner), one
fixed-typography row per move — muted classic coordinate (across = row-first `8G`, down =
column-first `H8`), the main word, points right-aligned; extra words of a multi-word play
on a smaller second line; italic localized notes for pass/exchange/resign/timeout; a
closing muted ± row for the endgame rack settlement when present. Footer:
`hostname · finish date` in the **device** locale. The scoresheet's height drives the
board side (never below its minimum): a long game stretches the board, never the
typography, so the image has no dead space.
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
offers *Export game* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than