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.
This commit is contained in:
Ilia Denisov
2026-07-13 12:08:27 +02:00
parent 93ccc8c449
commit 0f3b4dbcff
19 changed files with 778 additions and 77 deletions
+5 -4
View File
@@ -23,10 +23,11 @@ ENV APP_VERSION=${VERSION}
WORKDIR /app
COPY --from=build /src/renderer/node_modules ./node_modules
COPY --from=build /src/renderer/dist ./dist
COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs ./src/
# The public-offer prose (GET /offer/): the owner-edited markdown, read once at boot. The dynamic
# price list is fetched from the backend at request time and spliced in.
COPY ui/legal/offer_ru.md ./legal/offer_ru.md
COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs renderer/src/legal.mjs ./src/
# The public legal pages: the owner-edited markdown, read once at boot. GET /offer/ also splices in
# the live price list fetched from the backend at request time; GET /privacy/ and GET /eula/ are
# static (no dynamic data).
COPY ui/legal/offer_ru.md ui/legal/privacy_ru.md ui/legal/eula_ru.md ./legal/
USER node
EXPOSE 8090
CMD ["node", "src/server.mjs"]
+14 -8
View File
@@ -1,10 +1,10 @@
# renderer — the render sidecar (finished-game image + public offer page)
# renderer — the render sidecar (finished-game image + public legal pages)
An internal Node service that runs shared `ui/src/lib` renderers server-side — bundled verbatim at
image build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) so there is one renderer and no
drift from the browser. It serves two surfaces: the finished-game export **PNG** (on
[skia-canvas](https://github.com/samizdatco/skia-canvas), pixel-identical to the design the owner
signed off) and the public **offer page**.
signed off) and the public **legal pages** (the offer, the privacy policy and the EULA).
## Interface
@@ -17,8 +17,12 @@ signed off) and the public **offer page**.
(baked into the image, read at boot) with the live catalog **price list** (§4.4) fetched as
markdown from the backend's internal `/api/v1/internal/offer/pricing` (`RENDERER_BACKEND_URL`)
and spliced in at the `<#pricing_template#>` marker, then rendered by the shared
`ui/src/lib/offer.ts`. `GET /offer` → 301 `/offer/`. **The only edge-exposed route** — caddy
routes `/offer/` here; a backend outage yields a 502, never a stale price list.
`ui/src/lib/offer.ts` `renderLegalHtml`. `GET /offer` → 301 `/offer/`. Caddy routes `/offer/`
here; a backend outage yields a 502, never a stale price list.
- `GET /privacy/` — the privacy policy, and `GET /eula/` the end-user licence agreement, both as
`text/html`: the owner-edited `ui/legal/privacy_ru.md` / `ui/legal/eula_ru.md` (baked in, read at
boot), rendered by the same shared `renderLegalHtml`. **Static** — no backend fetch. `GET /privacy`
/ `GET /eula` → 301 to the trailing-slash form. Caddy routes these here too.
- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on).
The service renders and nothing else: for the PNG, authentication, the participant check and the
@@ -32,10 +36,12 @@ page.
```sh
pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml
# (allowBuilds) or the native binary is silently never fetched
pnpm test # bundles, then node --test (PNG smoke + offer splice)
# local run on :8090 (RENDERER_PORT overrides). For /offer/, point at the offer source and a
# reachable backend (else the boot read / the price fetch fail):
RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs
pnpm test # bundles, then node --test (PNG smoke + offer/legal render)
# local run on :8090 (RENDERER_PORT overrides). The server reads all three legal sources at boot, so
# point each at its ui/legal source (the baked paths do not exist in a local checkout); /offer/ also
# needs a reachable backend for the price fetch:
RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_PRIVACY_MD=../ui/legal/privacy_ru.md \
RENDERER_EULA_MD=../ui/legal/eula_ru.md RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs
```
The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the
+4 -3
View File
@@ -2,8 +2,9 @@
// 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.
// - renderOfferHtml / renderLegalHtml: the public legal pages — GET /offer/ (with the live
// catalog price list spliced in), GET /privacy/ and GET /eula/ (static) — server-side so one
// renderer serves them all.
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
export { setAlphabet } from '../../ui/src/lib/alphabet';
export { renderOfferHtml } from '../../ui/src/lib/offer';
export { renderOfferHtml, renderLegalHtml } from '../../ui/src/lib/offer';
+21
View File
@@ -0,0 +1,21 @@
// The public legal pages (GET /privacy/, GET /eula/): the owner-edited markdown under ui/legal/,
// rendered by the SAME shared renderLegalHtml the offer uses (bundled from ui/src/lib/offer). Unlike
// the offer, these carry no dynamic data — no backend fetch, no marker splice. Kept out of server.mjs
// so the per-page title/canonical presets are unit-testable without HTTP (test/legal.test.mjs).
import { renderLegalHtml } from '../dist/gameimage.mjs';
// renderPrivacy renders the privacy-policy markdown as the standalone /privacy/ page.
export function renderPrivacy(markdown) {
return renderLegalHtml(markdown, {
title: 'Политика конфиденциальности — Эрудит',
canonical: 'https://erudit-game.ru/privacy/',
});
}
// renderEula renders the end-user licence agreement markdown as the standalone /eula/ page.
export function renderEula(markdown) {
return renderLegalHtml(markdown, {
title: 'Пользовательское соглашение — Эрудит',
canonical: 'https://erudit-game.ru/eula/',
});
}
+31 -7
View File
@@ -2,19 +2,23 @@
// 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
// GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog
// price list spliced in; caddy routes /offer/ here)
// GET /privacy/ → text/html (the privacy policy: ui/legal/privacy_ru.md, static)
// GET /eula/ → text/html (the EULA: ui/legal/eula_ru.md, static)
// GET /offer, /privacy, /eula → 301 to the trailing-slash form
// GET /healthz → 200
//
// /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.
// public URL live in the backend). The legal pages (/offer/, /privacy/, /eula/) are reachable from the
// edge but read-only and unprivileged: /offer/ splices the price list fetched from the backend's
// internal endpoint into the committed offer markdown; /privacy/ and /eula/ are static committed
// markdown. No user input reaches any of them.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { renderRequest } from './render.mjs';
import { renderOffer } from './offer.mjs';
import { renderPrivacy, renderEula } from './legal.mjs';
const PORT = Number(process.env.RENDERER_PORT || 8090);
// A render request is a finished game's journal — generously capped.
@@ -25,6 +29,10 @@ const MAX_BODY = 2 * 1024 * 1024;
// 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');
// The privacy policy (GET /privacy/) and EULA (GET /eula/): static owner-edited markdown, read once at
// boot, no dynamic data. RENDERER_PRIVACY_MD / RENDERER_EULA_MD override the baked path for a local run.
const PRIVACY_MD = readFileSync(process.env.RENDERER_PRIVACY_MD || new URL('../legal/privacy_ru.md', import.meta.url), 'utf8');
const EULA_MD = readFileSync(process.env.RENDERER_EULA_MD || new URL('../legal/eula_ru.md', import.meta.url), 'utf8');
const OFFER_PRICING_URL =
(process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing';
@@ -74,6 +82,22 @@ const server = createServer(async (req, res) => {
.end(html);
return;
}
if (req.method === 'GET' && (path === '/privacy' || path === '/eula')) {
res.writeHead(301, { location: path + '/' }).end();
return;
}
if (req.method === 'GET' && path === '/privacy/') {
res
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
.end(renderPrivacy(PRIVACY_MD));
return;
}
if (req.method === 'GET' && path === '/eula/') {
res
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
.end(renderEula(EULA_MD));
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);
+21
View File
@@ -0,0 +1,21 @@
// Unit test for the static legal pages: renderPrivacy / renderEula wrap the owner-edited markdown in
// the shared legal-page chrome (bundled renderLegalHtml) with the right title + canonical URL and no
// dynamic data. The prose rendering itself is unit-tested in ui/ (offer.test.ts); here we assert the
// per-page presets.
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderPrivacy, renderEula } from '../src/legal.mjs';
test('renderPrivacy wraps the markdown in the privacy-page chrome', () => {
const html = renderPrivacy('# Политика конфиденциальности\n\n**1.1.** ИНН 290210610742.');
assert.ok(html.includes('<!doctype html>'), 'a standalone document');
assert.ok(html.includes('<title>Политика конфиденциальности — Эрудит</title>'), 'the privacy title');
assert.ok(html.includes('href="https://erudit-game.ru/privacy/"'), 'the privacy canonical URL');
assert.ok(html.includes('290210610742'), 'the prose reached the document');
});
test('renderEula wraps the markdown in the EULA-page chrome', () => {
const html = renderEula('# Лицензионное соглашение\n\nтекст');
assert.ok(html.includes('<title>Пользовательское соглашение — Эрудит</title>'), 'the EULA title');
assert.ok(html.includes('href="https://erudit-game.ru/eula/"'), 'the EULA canonical URL');
});