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.
This commit is contained in:
Ilia Denisov
2026-07-10 20:25:41 +02:00
parent a241e43d79
commit b6c2598710
32 changed files with 524 additions and 90 deletions
+7 -4
View File
@@ -1,6 +1,9 @@
// The esbuild bundle entry: re-exports the SHARED game-image renderer from ui/src/lib —
// the exact module the browser build unit-tests — plus the alphabet cache seeder the
// server fills from the backend-supplied per-variant table. Bundled by `pnpm run bundle`
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source).
// The esbuild bundle entry: re-exports the SHARED ui/src/lib modules — the exact modules the
// browser build unit-tests — so the sidecar runs them on the server. Bundled by `pnpm run bundle`
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source):
// - drawGameImage + setAlphabet: the finished-game PNG export (POST /render).
// - renderOfferHtml: the public-offer page (GET /offer/) — the same renderer the landing build
// used to invoke, now server-side so the live catalog price list can be spliced in.
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
export { setAlphabet } from '../../ui/src/lib/alphabet';
export { renderOfferHtml } from '../../ui/src/lib/offer';
+17
View File
@@ -0,0 +1,17 @@
// The public-offer page renderer: splices the live catalog price list into the owner-edited offer
// markdown and renders it with the SAME renderOfferHtml the landing build used to invoke (bundled
// from ui/src/lib/offer). Kept out of server.mjs so the substitution is unit-testable without HTTP.
import { renderOfferHtml } from '../dist/gameimage.mjs';
// pricingMarker is the token ui/legal/offer_ru.md carries at §4.4 where the price list belongs. It
// mirrors the backend marker (backend/internal/payments/offer.go) and is replaced with the fetched
// tables before rendering.
export const pricingMarker = '<#pricing_template#>';
// renderOffer returns the standalone /offer/ HTML: the offer markdown with the pricing marker
// replaced by pricingTables (the two markdown tables the backend projects from the active catalog),
// rendered to a self-contained document. The replacement is literal (a function replacer) so a "$"
// in a product title is never read as a replacement pattern; a missing marker leaves the text as is.
export function renderOffer(offerMarkdown, pricingTables) {
return renderOfferHtml(offerMarkdown.replace(pricingMarker, () => pricingTables));
}
+43 -8
View File
@@ -1,20 +1,43 @@
// The render sidecar: a minimal internal HTTP service the backend calls to rasterize the
// finished-game export image. It runs the same drawGameImage the browser build ships
// (bundled from ui/src/lib at image build time) on skia-canvas, with system fonts from
// the image (Liberation Sans + Noto Color Emoji via fontconfig).
// The render sidecar: a minimal internal HTTP service on skia-canvas and the shared ui/src/lib
// renderers (bundled from ui/src/lib at image build time). It serves two surfaces:
//
// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png
// GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog
// price list spliced in — the only edge-exposed route; caddy routes /offer/ here)
// GET /offer → 301 /offer/
// GET /healthz → 200
//
// The service is internal-only (docker network `internal`); authentication, participant
// checks and the signed public URL all live in the backend — this process only draws.
// /render is internal-only (the backend calls it; authentication, participant checks and the signed
// public URL live in the backend). /offer/ is reachable from the edge but read-only and unprivileged
// — it fetches the price list from the backend's internal endpoint and renders the committed offer
// markdown; no user input reaches it.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { renderRequest } from './render.mjs';
import { renderOffer } from './offer.mjs';
const PORT = Number(process.env.RENDERER_PORT || 8090);
// A render request is a finished game's journal — generously capped.
const MAX_BODY = 2 * 1024 * 1024;
// The offer prose is baked into the image (renderer/Dockerfile) and read once at boot; only the
// price list is dynamic (fetched per request). The backend endpoint is internal (off the edge
// allow-list) and serves the tables from its in-memory cache. RENDERER_OFFER_MD overrides the baked
// path for a local run (point it at ../ui/legal/offer_ru.md).
const OFFER_MD = readFileSync(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url), 'utf8');
const OFFER_PRICING_URL =
(process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing';
// fetchOfferPricing returns the two catalog price tables as markdown from the backend, or throws a
// 502 (upstream error) / 504 (timeout) so the offer never renders with a broken price list.
async function fetchOfferPricing() {
const resp = await fetch(OFFER_PRICING_URL, { signal: AbortSignal.timeout(5000) });
if (!resp.ok) {
throw Object.assign(new Error(`offer pricing upstream ${resp.status}`), { status: 502 });
}
return resp.text();
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
@@ -35,11 +58,23 @@ function readBody(req) {
const server = createServer(async (req, res) => {
try {
if (req.method === 'GET' && req.url === '/healthz') {
const path = (req.url || '').split('?')[0];
if (req.method === 'GET' && path === '/healthz') {
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
return;
}
if (req.method === 'POST' && req.url === '/render') {
if (req.method === 'GET' && path === '/offer') {
res.writeHead(301, { location: '/offer/' }).end();
return;
}
if (req.method === 'GET' && path === '/offer/') {
const html = renderOffer(OFFER_MD, await fetchOfferPricing());
res
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
.end(html);
return;
}
if (req.method === 'POST' && path === '/render') {
const png = await renderRequest(JSON.parse(await readBody(req)));
res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png);
return;