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
+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);