Compare commits

..

21 Commits

Author SHA1 Message Date
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
17 changed files with 35 additions and 406 deletions
-16
View File
@@ -497,22 +497,6 @@ jobs:
docker logs --tail 50 scrabble-backend || true docker logs --tail 50 scrabble-backend || true
exit 1 exit 1
- name: Probe the /offer/ public offer page is served
run: |
set -u
# /offer/ is a static page baked into the landing image (rendered from
# ui/legal/offer_ru.md). If the landing Caddyfile stops routing it, the request
# silently falls through to the landing shell (also 200) — so assert offer-specific
# content, never just the status.
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
if echo "$out" | grep -q "290210610742"; then
echo "ok: /offer/ serves the public offer page"
else
echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)"
docker logs --tail 50 scrabble-landing || true
exit 1
fi
- name: Probe the /dict edge route reaches the gateway - name: Probe the /dict edge route reaches the gateway
run: | run: |
set -u set -u
+14 -16
View File
@@ -33,7 +33,7 @@ status — without re-deriving decisions.
| E1 | Trusted platform signal | 1 | DONE | | E1 | Trusted platform signal | 1 | DONE |
| E2 | Currency + benefit core | 1 | DONE | | E2 | Currency + benefit core | 1 | DONE |
| E3 | Wallet UI | 1 | DONE | | E3 | Wallet UI | 1 | DONE |
| E4 | Durability (PITR) | 2 | DONE | | E4 | Durability (PITR) | 2 | WIP |
| E5 | Payment intake | 2 | TODO | | E5 | Payment intake | 2 | TODO |
| E6 | Ads | 2 | TODO | | E6 | Ads | 2 | TODO |
| E7 | Admin & reports | 2 | TODO | | E7 | Admin & reports | 2 | TODO |
@@ -446,8 +446,8 @@ noun agreement). Svelte whitespace/`$state` naming gotchas apply.
## E4 — Durability (PITR) ## E4 — Durability (PITR)
**Status:** DONE — armed + restore-drilled on prod (v1.13.0, 2026-07-09). · **Release 2** · **Status:** WIP — repo artifacts landed; **prod arming pending** (owner-coordinated, before
depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4). E5). · **Release 2** · depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4).
**Goal.** Continuous WAL archiving with point-in-time recovery, armed **before the first **Goal.** Continuous WAL archiving with point-in-time recovery, armed **before the first
real money** is accepted (E5 prod). Protects both money and game data. real money** is accepted (E5 prod). Protects both money and game data.
@@ -479,26 +479,24 @@ real money** is accepted (E5 prod). Protects both money and game data.
`-n backend`, silently excluding `payments`); manual-restore runbook updated. `-n backend`, silently excluding `payments`); manual-restore runbook updated.
- Full PITR runbook + arming sequence + recorded assessment in `deploy/README.md`. - Full PITR runbook + arming sequence + recorded assessment in `deploy/README.md`.
**Prod arming (completed 2026-07-09 with the v1.13.0 release).** Owner created the Selectel S3 **Prod arming (SEPARATE — owner-coordinated, before E5; NOT the artifacts PR).** Owner creates
bucket (`erudite`, ru-6) + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`); the Selectel S3 bucket + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
promoted `development → master` → `prod-deploy` (archive_mode on behind the maintenance window), then promote `development → master` → `prod-deploy` (archive_mode on behind the maintenance
then `stanza-create` + first base backup (31.9 MB cluster → 3.7 MB in the repo) + `check`, the window), `pgbackrest stanza-create` + first base backup + `check`, re-run Ansible with
Ansible `-e pitr_enabled=true` timer, and a restore drill on an isolated one-shot target (data `-e pitr_enabled=true`, and run the restore drill on an isolated one-shot target. Exact steps:
intact, then wiped). Exact steps: `deploy/README.md` (point-in-time recovery — arming). `deploy/README.md` (point-in-time recovery — arming).
**Tests.** Restore drill on an isolated one-shot instance (base + WAL → target timestamp), **Tests.** Restore drill on an isolated one-shot instance (base + WAL → target timestamp),
recorded in `deploy/README.md`. No app-level tests. Local verification: `docker compose config` recorded in `deploy/README.md`. No app-level tests. Local verification: `docker compose config`
valid + the custom PG image builds (archiving inert on the contour). valid + the custom PG image builds (archiving inert on the contour).
**Done-criteria (met).** WAL archiving live on prod PG (v1.13.0); a restore verified on an **Done-criteria.** Artifacts merged with archiving inert. **Fully done** at arming: WAL
isolated one-shot target; cost + perf assessment reviewed; runbook current in archiving live on prod PG; a timed restore verified; cost + perf assessment reviewed; runbook
`deploy/README.md`. current in `deploy/README.md`.
**Notes/risks.** The repository **cipher passphrase is unrecoverable if lost** — stored apart **Notes/risks.** The repository **cipher passphrase is unrecoverable if lost** — stored apart
from the S3 keys. Manual/timer pgBackRest runs use `docker exec -u postgres … --pg1-user=scrabble` from the S3 keys. Enabling `archive_mode` restarts postgres (rides the prod-deploy maintenance
(docker exec is root; the DB superuser role is `scrabble`, not `postgres`) — the systemd timer window). Migrations stay expand-contract so image rollback remains DB-safe alongside PITR.
+ the runbook carry this. Enabling `archive_mode` restarts postgres (rode the prod-deploy
maintenance window). Migrations stay expand-contract so image rollback remains DB-safe with PITR.
--- ---
+12 -31
View File
@@ -236,7 +236,6 @@ The main host archives Postgres continuously with **pgBackRest** to **Selectel S
(encrypted at rest, path-style addressing) so the database can be restored to any moment — (encrypted at rest, path-style addressing) so the database can be restored to any moment —
protecting the money ledger and the game state against corruption or host loss. It is the protecting the money ledger and the game state against corruption or host loss. It is the
primary recovery path; the migration-window `pg_dump` above is the secondary net. primary recovery path; the migration-window `pg_dump` above is the secondary net.
**Live on the prod main host since v1.13.0** (armed + restore-drilled 2026-07-09).
**Shape.** A daily full **base backup** (a systemd timer on the main host, `04:00`) plus **Shape.** A daily full **base backup** (a systemd timer on the main host, `04:00`) plus
**continuous WAL** archived by Postgres `archive_command` (a segment is forced at least every **continuous WAL** archived by Postgres `archive_command` (a segment is forced at least every
@@ -259,9 +258,8 @@ absent/NaN-safe, so they stay quiet until archiving is armed.
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At `pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
30-day retention on Selectel S3 (~2 ₽/GB·month) the archive is **under ~0.3 GB → well under 30-day retention on Selectel S3 (~2 ₽/GB·month) the archive is **under ~0.3 GB → well under
1 ₽/month** (compression halves it again); request volume is trivial. Performance impact is 1 ₽/month** (compression halves it again); request volume is trivial. Performance impact is
**negligible**: archive-push moves tiny compressed segments, and the daily full base backup is **negligible**: archive-push moves tiny compressed segments, and the daily full base backup
small (the whole cluster is ~32 MB → ~3.7 MB compressed in the repo) and checkpoint-bound is a ~10 MB, sub-second job on the 2 vCPU host. Revisit both if traffic grows ~100× (watch
(~1.5 min wall, minimal CPU/I-O) on the 2 vCPU host. Revisit if traffic grows ~100× (watch
`node_exporter` during a base backup). `node_exporter` during a base backup).
**Arming (owner-coordinated, once, before real payments).** Ships disarmed; to turn it on: **Arming (owner-coordinated, once, before real payments).** Ships disarmed; to turn it on:
@@ -278,22 +276,14 @@ small (the whole cluster is ~32 MB → ~3.7 MB compressed in the repo) and check
2. Promote `development → master`, tag, and run **prod-deploy**. The roll recreates postgres 2. Promote `development → master`, tag, and run **prod-deploy**. The roll recreates postgres
with `archive_mode=on` behind the maintenance page. (Archive pushes fail harmlessly for the with `archive_mode=on` behind the maintenance page. (Archive pushes fail harmlessly for the
minute until step 3 creates the repository — the WAL is retained, not lost.) minute until step 3 creates the repository — the WAL is retained, not lost.)
3. On the main host, create the repository, verify archiving, take the first base backup. Run 3. On the main host, create the repository, take the first base backup, and verify:
pgBackRest **as the `postgres` OS user** (`-u postgres` — lock-dir/PGDATA consistency with
`archive-push`) and connect to the DB **as the superuser role** (`--pg1-user=scrabble`, the
`POSTGRES_USER`, not `postgres`); it inherits the container's `PGBACKREST_*` env:
```sh ```sh
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble stanza-create docker exec scrabble-postgres pgbackrest --stanza=scrabble stanza-create
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble --type=full backup docker exec scrabble-postgres pgbackrest --stanza=scrabble check
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble info # (info takes no --pg1-user)
``` ```
The few `archive-push` failures logged between `archive_mode=on` and `stanza-create` are the 4. Enable the daily timer: `ansible-playbook site.yml -e pitr_enabled=true` (installs + starts
expected transient (WAL retained, not lost); clear the counter afterwards with `pgbackrest-backup.timer` on the main host).
`docker exec -u postgres scrabble-postgres psql -U scrabble -d scrabble -c "SELECT pg_stat_reset_shared('archiver');"`
so it does not trip the failing-archive alert.
4. Enable the daily timer: `ansible-playbook site.yml --limit main -e pitr_enabled=true`
(installs + starts `pgbackrest-backup.timer` on the main host).
5. Confirm in Grafana that the two archiving alerts are green (`last_archive_age` now tracks a 5. Confirm in Grafana that the two archiving alerts are green (`last_archive_age` now tracks a
real number, `failed_count` flat). real number, `failed_count` flat).
@@ -302,26 +292,17 @@ an **isolated, one-shot** target (a throwaway VM or a container with no ingress)
the matching Postgres major, an empty `PGDATA`, and the same `PGBACKREST_*` environment: the matching Postgres major, an empty `PGDATA`, and the same `PGBACKREST_*` environment:
```sh ```sh
# Throwaway container from the DB image (carries pgBackRest), the live PGBACKREST_* env, an pgbackrest --stanza=scrabble --type=time "--target=YYYY-MM-DD HH:MM:SS+00" --delta restore
# empty PGDATA and no app network; restore, recover, verify, then wipe (-v drops the data). # start Postgres; it replays WAL to the target; then verify a known row / the ledger tail
IMG=$(docker inspect -f '{{.Config.Image}}' scrabble-postgres)
docker exec scrabble-postgres env | grep '^PGBACKREST_' > /tmp/drill.env; echo POSTGRES_PASSWORD=drill >> /tmp/drill.env
docker run -d --name pitr-drill --env-file /tmp/drill.env --entrypoint sleep "$IMG" infinity
docker exec pitr-drill sh -c 'rm -rf /var/lib/postgresql/data/*; chown -R postgres:postgres /var/lib/postgresql/data; chmod 700 /var/lib/postgresql/data'
# --type=immediate = to the base backup's consistency point; --type=time "--target=<ts>" for PITR
docker exec -u postgres pitr-drill pgbackrest --stanza=scrabble --pg1-path=/var/lib/postgresql/data --type=immediate restore
docker exec -u postgres pitr-drill pg_ctl -D /var/lib/postgresql/data -w start
docker exec -u postgres pitr-drill psql -U scrabble -d scrabble -c "SELECT count(*) FROM backend.accounts;" # verify data intact
docker rm -fv pitr-drill; rm -f /tmp/drill.env # wipe the restored real data
``` ```
The target holds **real money + personal data** while it exists — keep it network-isolated and The target holds **real money + personal data** while it exists — keep it network-isolated and
**destroy it (wipe `PGDATA` + the instance) afterwards**. Record each drill (date, target **destroy it (wipe `PGDATA` + the instance) afterwards**. Record each drill (date, target
timestamp, outcome) here: timestamp, outcome) here:
| Date | Target | Result | | Date | Target timestamp | Result |
| --- | --- | --- | | --- | --- | --- |
| 2026-07-09 | latest (`--type=immediate`) | PASS — v1.13.0 arming: 31.9 MB cluster restored from the encrypted S3 repo (3.7 MB compressed), recovered to consistency, `backend.accounts` intact; drill instance wiped. | | _pending first arming_ | — | — |
**bot-link cert rotation:** regenerate (`deploy/gen-certs.sh /tmp/c --force`), reset the **bot-link cert rotation:** regenerate (`deploy/gen-certs.sh /tmp/c --force`), reset the
five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy
@@ -10,8 +10,6 @@ Requires=docker.service
[Service] [Service]
Type=oneshot Type=oneshot
# docker exec defaults to root, so run as the postgres OS user (-u postgres) for lock-dir and # docker exec runs as the image's postgres user and inherits the container's PGBACKREST_*
# PGDATA consistency with archive-push, and connect to the database as the superuser role via # environment (the S3 repository + cipher), so no repository config is needed on the host.
# --pg1-user: that role is the POSTGRES_USER (scrabble), not "postgres". It inherits the ExecStart=/usr/bin/docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
# container's PGBACKREST_* environment (the S3 repository + cipher), so no host-side config.
ExecStart=/usr/bin/docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble --type=full backup
-15
View File
@@ -18,25 +18,10 @@
@shell not path /assets/* @shell not path /assets/*
header @shell Cache-Control "no-cache" header @shell Cache-Control "no-cache"
# The static public offer page, rendered from ui/legal/offer_ru.md at build
# time into dist/offer/index.html (vite emit-offer plugin). Served with its own
# index so /offer/ resolves to /srv/offer/index.html rather than falling to the
# landing shell below (whose index is landing.html). A bare /offer redirects in.
handle /offer {
redir * /offer/ permanent
}
handle /offer/* {
file_server {
index index.html
}
}
# An unknown path falls back to the landing shell (the gateway's old "/" # An unknown path falls back to the landing shell (the gateway's old "/"
# behaviour); "/" itself resolves through the index below. # behaviour); "/" itself resolves through the index below.
handle {
try_files {path} /landing.html try_files {path} /landing.html
file_server { file_server {
index landing.html index landing.html
} }
} }
}
+1 -2
View File
@@ -1328,8 +1328,7 @@ in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth a
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the **admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
catch-all — notably the landing at `/`, plus the static public offer at `/offer/` catch-all — notably the landing at `/` — goes to the landing container. The
(rendered from `ui/legal/offer_ru.md` at build time) — goes to the landing container. The
**Telegram validator** runs as a separate container with **no public ingress**, **Telegram validator** runs as a separate container with **no public ingress**,
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to
-11
View File
@@ -58,14 +58,3 @@ test('the landing shows a web-version entry linking /app/, with a caption', asyn
expect(await web.getAttribute('href')).toContain('/app/'); expect(await web.getAttribute('href')).toContain('/app/');
await expect(page.getByText('Веб-версия')).toBeVisible(); await expect(page.getByText('Веб-версия')).toBeVisible();
}); });
// The footer carries a public-offer link to the static /offer/ page (rendered from
// ui/legal/offer_ru.md at build; the legal document a purchase accepts).
test('the landing footer links to the public offer at /offer/', async ({ page }) => {
await page.goto('/landing.html');
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); // Russian by default
const offer = page.getByRole('link', { name: 'Публичная оферта' });
await expect(offer).toBeVisible();
expect(await offer.getAttribute('href')).toBe('/offer/');
});
-145
View File
@@ -1,145 +0,0 @@
# Публичная оферта
Публичная оферта о заключении договора купли-продажи.
## 1. Общие положения
В настоящей Публичной оферте содержатся условия заключения Договора купли-продажи (далее по тексту — «Договор купли-продажи» и/или «Договор»). Настоящей офертой признается предложение, адресованное одному или нескольким конкретным лицам, которое достаточно определенно и выражает намерение лица, сделавшего предложение, считать себя заключившим Договор с адресатом, которым будет принято предложение.
Совершение указанных в настоящей Оферте действий является подтверждением согласия обеих Сторон заключить Договор купли-продажи на условиях, в порядке и объеме, изложенных в настоящей Оферте.
Нижеизложенный текст Публичной оферты является официальным публичным предложением Продавца, адресованный заинтересованному кругу лиц заключить Договор купли-продажи в соответствии с положениями пункта 2 статьи 437 Гражданского кодекса РФ.
Договор купли-продажи считается заключенным и приобретает силу с момента совершения Сторонами действий, предусмотренных в настоящей Оферте, и, означающих безоговорочное, а также полное принятие всех условий настоящей Оферты без каких-либо изъятий или ограничений на условиях присоединения.
### Термины и определения
**Договор** — текст настоящей Оферты с Приложениями, являющимися неотъемлемой частью настоящей Оферты, акцептованный Покупателем путем совершения конклюдентных действий, предусмотренных настоящей Офертой.
**Конклюдентные действия** — это поведение, которое выражает согласие с предложением контрагента заключить, изменить или расторгнуть договор. Действия состоят в полном или частичном выполнении условий, которые предложил контрагент.
**Сайт Продавца в сети «Интернет»** — совокупность программ для электронных вычислительных машин и иной информации, содержащейся в информационной системе, доступ к которой обеспечивается посредством сети «Интернет» по доменному имени и сетевому адресу: `erudit-game.ru`.
**Стороны Договора (Стороны)** — Продавец и Покупатель.
**Товар** — товаром по договору купли-продажи могут быть любые вещи с соблюдением правил, предусмотренных статьей 129 Гражданского кодекса РФ.
## 2. Предмет Договора
**2.1.** По настоящему Договору Продавец обязуется передать вещь (Товар) в собственность Покупателя, а Покупатель обязуется принять Товар и уплатить за него определенную денежную сумму.
**2.2.** Наименование, количество, а также ассортимент Товара, его стоимость, порядок доставки и иные условия определяются на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет» `erudit-game.ru`.
**2.3.** Акцепт настоящей Оферты выражается в совершении конклюдентных действий, в частности:
- действиях, связанных с регистрацией учетной записи на Сайте Продавца в сети «Интернет» при наличии необходимости регистрации учетной записи;
- путем составления и заполнения заявки на оформление заказа Товара;
- путем сообщения требуемых для заключения Договора сведений по телефону, электронной почте, указанными на сайте Продавца в сети «Интернет», в том числе, при обратном звонке Продавца по заявке Покупателя;
- оплаты Товара Покупателем.
Данный перечень не является исчерпывающим, могут быть и другие действия, которые ясно выражают намерение лица принять предложение контрагента.
## 3. Права и обязанности Сторон
### 3.1. Права и обязанности Продавца
**3.1.1.** Продавец вправе требовать оплаты Товаров и их доставки в порядке и на условиях, предусмотренных Договором;
**3.1.2.** Отказать в заключении Договора на основании настоящей Оферты Покупателю в случае его недобросовестного поведения, в частности, в случае:
- более 2 (Двух) отказов от Товаров надлежащего качества в течение года;
- предоставления заведомо недостоверной персональной информации;
- возврата испорченного Покупателем Товара или Товара, бывшего в употреблении;
- иных случаях недобросовестного поведения, свидетельствующих о заключении Покупателем Договора с целью злоупотребления правами, и отсутствия обычной экономической цели Договора — приобретения Товара.
**3.1.3.** Продавец обязуется передать Покупателю Товар надлежащего качества и в надлежащей упаковке;
**3.1.4.** Передать Товар свободным от прав третьих лиц;
**3.1.5.** Организовать доставку Товаров Покупателю;
**3.1.6.** Предоставить Покупателю всю необходимую информацию в соответствии с требованиями действующего законодательства РФ и настоящей Оферты;
### 3.2. Права и обязанности Покупателя
**3.2.1.** Покупатель вправе требовать передачи Товара в порядке и на условиях, предусмотренных Договором.
**3.2.2.** Требовать предоставления всей необходимой информации в соответствии с требованиями действующего законодательства РФ и настоящей Оферты;
**3.2.3.** Отказаться от Товара по основаниям, предусмотренным Договором и действующим законодательством Российской Федерации.
**3.2.4.** Покупатель обязуется предоставить Продавцу достоверную информацию, необходимую для надлежащего исполнения Договора;
**3.2.5.** Принять и оплатить Товар в соответствии с условиями Договора;
**3.2.6.** Покупатель гарантирует, что все условия Договора ему понятны; Покупатель принимает условия без оговорок, а также в полном объеме.
## 4. Цена и порядок расчетов
**4.1.** Стоимость, а также порядок оплаты Товара определяется на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет»: `erudit-game.ru`.
**4.2.** Все расчеты по Договору производятся в безналичном порядке.
## 5. Обмен и возврат Товара
**5.1.** Покупатель вправе осуществить возврат (обмен) Продавцу Товара, приобретенный дистанционным способом, за исключением перечня товаров, не подлежащих обмену и возврату согласно действующему законодательству Российской Федерации. Условия, сроки и порядок возврата Товара надлежащего и ненадлежащего качества установлены в соответствии с Гражданским кодексом РФ, Закона РФ от 07.02.1992 N 2300-1 «О защите прав потребителей», Правил, утвержденных Постановлением Правительства РФ от 31.12.2020 N 2463.
**5.2.** Требование Покупателя об обмене либо о возврате Товара рассматривается индивидуально при условии неиспользования приобретенного товара и наличии уважительных причин (технический сбой, ошибка в описании товара).
## 6. Конфиденциальность и безопасность
**6.1.** При реализации настоящего Договора Стороны обеспечивают конфиденциальность и безопасность персональных данных в соответствии с актуальной редакцией ФЗ от 27.07.2006 г. № 152-ФЗ «О персональных данных» и ФЗ от 27.07.2006 г. № 149-ФЗ «Об информации, информационных технологиях и о защите информации».
**6.2.** Стороны обязуются сохранять конфиденциальность информации, полученной в ходе исполнения настоящего Договора, и принять все возможные меры, чтобы предохранить полученную информацию от разглашения.
**6.3.** Под конфиденциальной информацией понимается любая информация, передаваемая Продавцом и Покупателем в процессе реализации Договора и подлежащая защите, исключения указаны ниже.
**6.4.** Такая информация может содержаться в предоставляемых Продавцом локальных нормативных актах, договорах, письмах, отчетах, аналитических материалах, результатах исследований, схемах, графиках, спецификациях и других документах, оформленных как на бумажных, так и на электронных носителях.
## 7. Форс-мажор
**7.1.** Стороны освобождаются от ответственности за неисполнение или ненадлежащее исполнение обязательств по Договору, если надлежащее исполнение оказалось невозможным вследствие непреодолимой силы, то есть чрезвычайных и непредотвратимых при данных условиях обстоятельств, под которыми понимаются: запретные действия властей, эпидемии, блокада, эмбарго, землетрясения, наводнения, пожары или другие стихийные бедствия.
**7.2.** В случае наступления этих обстоятельств Сторона обязана в течение 30 (Тридцати) рабочих дней уведомить об этом другую Сторону.
**7.3.** Документ, выданный уполномоченным государственным органом, является достаточным подтверждением наличия и продолжительности действия непреодолимой силы.
**7.4.** Если обстоятельства непреодолимой силы продолжают действовать более 60 (Шестидесяти) рабочих дней, то каждая Сторона вправе отказаться от настоящего Договора в одностороннем порядке.
## 8. Ответственность Сторон
**8.1.** В случае неисполнения и/или ненадлежащего исполнения своих обязательств по Договору, Стороны несут ответственность в соответствии с условиями настоящей Оферты.
**8.2.** Сторона, не исполнившая или ненадлежащим образом исполнившая обязательства по Договору, обязана возместить другой Стороне причиненные такими нарушениями убытки.
## 9. Срок действия настоящей Оферты
**9.1.** Оферта вступает в силу с момента размещения на Сайте Продавца и действует до момента её отзыва Продавцом.
**9.2.** Продавец оставляет за собой право внести изменения в условия Оферты и/или отозвать Оферту в любой момент по своему усмотрению. Сведения об изменении или отзыве Оферты доводятся до Покупателя по выбору Продавца посредством размещения на сайте Продавца в сети «Интернет», в Личном кабинете Покупателя, либо путем направления соответствующего уведомления на электронный или почтовый адрес, указанный Покупателем при заключении Договора или в ходе его исполнения.
**9.3.** Договор вступает в силу с момента Акцепта условий настоящей Оферты Покупателем и действует до полного исполнения Сторонами обязательств по Договору.
**9.4.** Изменения, внесенные Продавцом в Договор и опубликованные на сайте в форме актуализированной Оферты, считаются принятыми Покупателем в полном объеме.
## 10. Дополнительные условия
**10.1.** Договор, его заключение и исполнение регулируется действующим законодательством Российской Федерации. Все вопросы, не урегулированные настоящей Офертой или урегулированные не полностью, регулируются в соответствии с материальным правом Российской Федерации.
**10.2.** В случае возникновения спора, который может возникнуть между Сторонами в ходе исполнения ими своих обязательств по Договору, заключенному на условиях настоящей Оферты, Стороны обязаны урегулировать спор мирным путем до начала судебного разбирательства.
Судебное разбирательство осуществляется в соответствии с законодательством Российской Федерации.
Споры или разногласия, по которым Стороны не достигли договоренности, подлежат разрешению в соответствии с законодательством РФ. Досудебный порядок урегулирования спора является обязательным.
**10.3.** В качестве языка Договора, заключаемого на условиях настоящей Оферты, а также языка, используемого при любом взаимодействии Сторон (включая ведение переписки, предоставление требований / уведомлений / разъяснений, предоставление документов и т. д.), Стороны определили русский язык.
**10.4.** Все документы, подлежащие предоставлению в соответствии с условиями настоящей Оферты, должны быть составлены на русском языке либо иметь перевод на русский язык, удостоверенный в установленном порядке.
**10.5.** Бездействие одной из Сторон в случае нарушения условий настоящей Оферты не лишает права заинтересованной Стороны осуществлять защиту своих интересов позднее, а также не означает отказа от своих прав в случае совершения одной из Сторон подобных либо сходных нарушений в будущем.
**10.6.** Если на Сайте Продавца в сети «Интернет» есть ссылки на другие веб-сайты и материалы третьих лиц, такие ссылки размещены исключительно в целях информирования, и Продавец не имеет контроля в отношении содержания таких сайтов или материалов. Продавец не несет ответственность за любые убытки или ущерб, которые могут возникнуть в результате использования таких ссылок.
## 11. Реквизиты Продавца
Денисов Илья Аркадьевич, ИНН 290210610742.
-1
View File
@@ -28,7 +28,6 @@
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"core-js-bundle": "^3.49.0", "core-js-bundle": "^3.49.0",
"marked": "^18.0.5",
"svelte": "^5.15.0", "svelte": "^5.15.0",
"svelte-check": "^4.1.0", "svelte-check": "^4.1.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
-10
View File
@@ -39,9 +39,6 @@ importers:
core-js-bundle: core-js-bundle:
specifier: ^3.49.0 specifier: ^3.49.0
version: 3.49.0 version: 3.49.0
marked:
specifier: ^18.0.5
version: 18.0.5
svelte: svelte:
specifier: ^5.15.0 specifier: ^5.15.0
version: 5.56.0 version: 5.56.0
@@ -1631,11 +1628,6 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@18.0.5:
resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3885,8 +3877,6 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
marked@18.0.5: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
minimatch@10.2.5: minimatch@10.2.5:
+1 -14
View File
@@ -123,10 +123,7 @@
</div> </div>
</section> </section>
<footer class="ft"> <footer class="ft">{t('about.version', { v: __APP_VERSION__ })}</footer>
<a class="offer" href="/offer/">{t('landing.offer')}</a>
<span>{t('about.version', { v: __APP_VERSION__ })}</span>
</footer>
</main> </main>
<style> <style>
@@ -278,18 +275,8 @@
} }
.ft { .ft {
margin-top: auto; margin-top: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.8rem; font-size: 0.8rem;
} }
.ft .offer {
color: inherit;
}
.ft .offer:hover {
color: var(--accent);
}
</style> </style>
-1
View File
@@ -275,7 +275,6 @@ export const en = {
'landing.captionTelegram': 'Telegram', 'landing.captionTelegram': 'Telegram',
'landing.captionVK': 'VK', 'landing.captionVK': 'VK',
'landing.captionWeb': 'Web', 'landing.captionWeb': 'Web',
'landing.offer': 'Public offer',
'install.title': 'Install the app', 'install.title': 'Install the app',
'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.', 'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.',
-1
View File
@@ -275,7 +275,6 @@ export const ru: Record<MessageKey, string> = {
'landing.captionTelegram': 'Telegram', 'landing.captionTelegram': 'Telegram',
'landing.captionVK': 'VK', 'landing.captionVK': 'VK',
'landing.captionWeb': 'Веб-версия', 'landing.captionWeb': 'Веб-версия',
'landing.offer': 'Публичная оферта',
'install.title': 'Установить приложение', 'install.title': 'Установить приложение',
'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.', 'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.',
-23
View File
@@ -1,23 +0,0 @@
import { describe, it, expect } from 'vitest';
import { renderOfferHtml } from './offer';
describe('renderOfferHtml', () => {
it('wraps rendered markdown in a standalone Russian HTML document', () => {
const html = renderOfferHtml('# Публичная оферта\n\nо заключении договора купли-продажи');
expect(html).toContain('<!doctype html>');
expect(html).toContain('lang="ru"');
expect(html).toContain('<title>Публичная оферта — Эрудит</title>');
// The markdown heading is rendered, not left as literal source.
expect(html).toContain('<h1>Публичная оферта</h1>');
expect(html).not.toContain('# Публичная оферта');
// A back link to the landing root is always present.
expect(html).toContain('href="/"');
});
it('renders headings, bold clause numbers and lists', () => {
const html = renderOfferHtml('## 2. Предмет\n\n**2.1.** Текст пункта.\n\n- первый\n- второй');
expect(html).toContain('<h2>2. Предмет</h2>');
expect(html).toContain('<strong>2.1.</strong>');
expect(html).toContain('<li>первый</li>');
});
});
-93
View File
@@ -1,93 +0,0 @@
import { marked } from 'marked';
/**
* renderOfferHtml renders the public-offer markdown source into a standalone,
* self-contained HTML document served statically at `/offer/`. The build emits
* the result as `dist/offer/index.html` (see the `emit-offer` plugin in
* `vite.config.ts`), which the landing container serves.
*
* The input `markdown` is trusted repository content (the owner-edited
* `ui/legal/offer_ru.md`), not user input, so the rendered HTML is deliberately
* not sanitised. The page carries its own minimal light/dark styling so it needs
* neither the app bundle nor `app.css`.
*/
export function renderOfferHtml(markdown: string): string {
const body = marked.parse(markdown, { async: false }) as string;
return `<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Публичная оферта — Эрудит</title>
<link rel="canonical" href="https://erudit-game.ru/offer/" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f3f4f6" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f1115" />
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--text: #1a1c20;
--accent: #2563eb;
--rule: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--text: #e7e9ee;
--accent: #6ea8fe;
--rule: #242832;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
main {
max-width: 760px;
margin: 0 auto;
padding: 24px 20px 64px;
}
a {
color: var(--accent);
}
.back {
display: inline-block;
margin-bottom: 20px;
text-decoration: none;
}
h1 {
font-size: 1.6rem;
text-align: center;
}
h2 {
font-size: 1.2rem;
margin-top: 2em;
padding-top: 1em;
border-top: 1px solid var(--rule);
}
h3 {
font-size: 1.05rem;
margin-top: 1.5em;
}
ul {
padding-left: 1.25em;
}
li {
margin: 0.25em 0;
}
</style>
</head>
<body>
<main>
<a class="back" href="/">← На главную</a>
${body}
</main>
</body>
</html>
`;
}
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"types": ["node"] "types": ["node"]
}, },
"include": ["vite.config.ts", "src/lib/offer.ts"] "include": ["vite.config.ts"]
} }
-18
View File
@@ -3,7 +3,6 @@ import { resolve } from 'node:path';
import { defineConfig, type Plugin } from 'vite'; import { defineConfig, type Plugin } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelte } from '@sveltejs/vite-plugin-svelte';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
import { renderOfferHtml } from './src/lib/offer';
/** /**
* injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its * injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its
@@ -39,22 +38,6 @@ function emitPolyfills(): Plugin {
}; };
} }
/**
* emitOffer renders the public offer markdown (legal/offer_ru.md) to a standalone
* static page and emits it as dist/offer/index.html, which the landing container
* serves at /offer/ (deploy/landing/Caddyfile). The markdown is the owner-editable
* source of truth, so the page is regenerated from it on every build.
*/
function emitOffer(): Plugin {
return {
name: 'emit-offer',
generateBundle() {
const md = readFileSync(resolve(import.meta.dirname, 'legal/offer_ru.md'), 'utf8');
this.emitFile({ type: 'asset', fileName: 'offer/index.html', source: renderOfferHtml(md) });
},
};
}
// The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over // The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over
// h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can // h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can
// not speak h2c directly) talks to the dev server on the same origin. In `mock` // not speak h2c directly) talks to the dev server on the same origin. In `mock`
@@ -77,7 +60,6 @@ export default defineConfig(({ mode }) => ({
plugins: [ plugins: [
svelte(), svelte(),
emitPolyfills(), emitPolyfills(),
emitOffer(),
injectBootVersion(), injectBootVersion(),
// App-shell precache for the offline mode: a custom (injectManifest) service worker precaches // App-shell precache for the offline mode: a custom (injectManifest) service worker precaches
// index.html + the hashed assets so the installed web PWA cold-launches with no network. It // index.html + the hashed assets so the installed web PWA cold-launches with no network. It