Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -497,22 +497,6 @@ jobs:
|
||||
docker logs --tail 50 scrabble-backend || true
|
||||
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
|
||||
run: |
|
||||
set -u
|
||||
|
||||
@@ -33,7 +33,7 @@ status — without re-deriving decisions.
|
||||
| E1 | Trusted platform signal | 1 | DONE |
|
||||
| E2 | Currency + benefit core | 1 | DONE |
|
||||
| E3 | Wallet UI | 1 | DONE |
|
||||
| E4 | Durability (PITR) | 2 | DONE |
|
||||
| E4 | Durability (PITR) | 2 | WIP |
|
||||
| E5 | Payment intake | 2 | TODO |
|
||||
| E6 | Ads | 2 | TODO |
|
||||
| E7 | Admin & reports | 2 | TODO |
|
||||
@@ -446,8 +446,8 @@ noun agreement). Svelte whitespace/`$state` naming gotchas apply.
|
||||
|
||||
## E4 — Durability (PITR)
|
||||
|
||||
**Status:** DONE — armed + restore-drilled on prod (v1.13.0, 2026-07-09). · **Release 2** ·
|
||||
depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4).
|
||||
**Status:** WIP — repo artifacts landed; **prod arming pending** (owner-coordinated, before
|
||||
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
|
||||
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.
|
||||
- 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
|
||||
bucket (`erudite`, ru-6) + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
|
||||
promoted `development → master` → `prod-deploy` (archive_mode on behind the maintenance window),
|
||||
then `stanza-create` + first base backup (31.9 MB cluster → 3.7 MB in the repo) + `check`, the
|
||||
Ansible `-e pitr_enabled=true` timer, and a restore drill on an isolated one-shot target (data
|
||||
intact, then wiped). Exact steps: `deploy/README.md` (point-in-time recovery — arming).
|
||||
**Prod arming (SEPARATE — owner-coordinated, before E5; NOT the artifacts PR).** Owner creates
|
||||
the Selectel S3 bucket + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
|
||||
then promote `development → master` → `prod-deploy` (archive_mode on behind the maintenance
|
||||
window), `pgbackrest stanza-create` + first base backup + `check`, re-run Ansible with
|
||||
`-e pitr_enabled=true`, and run the restore drill on an isolated one-shot target. Exact steps:
|
||||
`deploy/README.md` (point-in-time recovery — arming).
|
||||
|
||||
**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`
|
||||
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
|
||||
isolated one-shot target; cost + perf assessment reviewed; runbook current in
|
||||
`deploy/README.md`.
|
||||
**Done-criteria.** Artifacts merged with archiving inert. **Fully done** at arming: WAL
|
||||
archiving live on prod PG; a timed restore verified; cost + perf assessment reviewed; runbook
|
||||
current in `deploy/README.md`.
|
||||
|
||||
**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`
|
||||
(docker exec is root; the DB superuser role is `scrabble`, not `postgres`) — the systemd timer
|
||||
+ 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.
|
||||
from the S3 keys. Enabling `archive_mode` restarts postgres (rides the prod-deploy maintenance
|
||||
window). Migrations stay expand-contract so image rollback remains DB-safe alongside PITR.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+12
-31
@@ -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 —
|
||||
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.
|
||||
**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
|
||||
**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
|
||||
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
|
||||
**negligible**: archive-push moves tiny compressed segments, and the daily full base backup is
|
||||
small (the whole cluster is ~32 MB → ~3.7 MB compressed in the repo) and checkpoint-bound
|
||||
(~1.5 min wall, minimal CPU/I-O) on the 2 vCPU host. Revisit if traffic grows ~100× (watch
|
||||
**negligible**: archive-push moves tiny compressed segments, and the daily full base backup
|
||||
is a ~10 MB, sub-second job on the 2 vCPU host. Revisit both if traffic grows ~100× (watch
|
||||
`node_exporter` during a base backup).
|
||||
|
||||
**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
|
||||
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.)
|
||||
3. On the main host, create the repository, verify archiving, take the first base backup. Run
|
||||
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:
|
||||
3. On the main host, create the repository, take the first base backup, and verify:
|
||||
```sh
|
||||
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble stanza-create
|
||||
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check
|
||||
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble --type=full backup
|
||||
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble info # (info takes no --pg1-user)
|
||||
docker exec scrabble-postgres pgbackrest --stanza=scrabble stanza-create
|
||||
docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
|
||||
docker exec scrabble-postgres pgbackrest --stanza=scrabble check
|
||||
```
|
||||
The few `archive-push` failures logged between `archive_mode=on` and `stanza-create` are the
|
||||
expected transient (WAL retained, not lost); clear the counter afterwards with
|
||||
`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).
|
||||
4. Enable the daily timer: `ansible-playbook site.yml -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
|
||||
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:
|
||||
|
||||
```sh
|
||||
# Throwaway container from the DB image (carries pgBackRest), the live PGBACKREST_* env, an
|
||||
# empty PGDATA and no app network; restore, recover, verify, then wipe (-v drops the data).
|
||||
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
|
||||
pgbackrest --stanza=scrabble --type=time "--target=YYYY-MM-DD HH:MM:SS+00" --delta restore
|
||||
# start Postgres; it replays WAL to the target; then verify a known row / the ledger tail
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy
|
||||
|
||||
@@ -10,8 +10,6 @@ Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# docker exec defaults to root, so run as the postgres OS user (-u postgres) for lock-dir and
|
||||
# PGDATA consistency with archive-push, and connect to the database as the superuser role via
|
||||
# --pg1-user: that role is the POSTGRES_USER (scrabble), not "postgres". It inherits the
|
||||
# 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
|
||||
# docker exec runs as the image's postgres user and inherits the container's PGBACKREST_*
|
||||
# environment (the S3 repository + cipher), so no repository config is needed on the host.
|
||||
ExecStart=/usr/bin/docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
|
||||
|
||||
@@ -18,25 +18,10 @@
|
||||
@shell not path /assets/*
|
||||
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 "/"
|
||||
# behaviour); "/" itself resolves through the index below.
|
||||
handle {
|
||||
try_files {path} /landing.html
|
||||
file_server {
|
||||
index landing.html
|
||||
}
|
||||
try_files {path} /landing.html
|
||||
file_server {
|
||||
index landing.html
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
catch-all — notably the landing at `/`, plus the static public offer at `/offer/`
|
||||
(rendered from `ui/legal/offer_ru.md` at build time) — goes to the landing container. The
|
||||
catch-all — notably the landing at `/` — goes to the landing container. The
|
||||
**Telegram validator** runs as a separate container with **no public ingress**,
|
||||
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
|
||||
|
||||
@@ -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/');
|
||||
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/');
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
@@ -28,7 +28,6 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"core-js-bundle": "^3.49.0",
|
||||
"marked": "^18.0.5",
|
||||
"svelte": "^5.15.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
Generated
-10
@@ -39,9 +39,6 @@ importers:
|
||||
core-js-bundle:
|
||||
specifier: ^3.49.0
|
||||
version: 3.49.0
|
||||
marked:
|
||||
specifier: ^18.0.5
|
||||
version: 18.0.5
|
||||
svelte:
|
||||
specifier: ^5.15.0
|
||||
version: 5.56.0
|
||||
@@ -1631,11 +1628,6 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
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:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3885,8 +3877,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
marked@18.0.5: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
minimatch@10.2.5:
|
||||
|
||||
+1
-14
@@ -123,10 +123,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="ft">
|
||||
<a class="offer" href="/offer/">{t('landing.offer')}</a>
|
||||
<span>{t('about.version', { v: __APP_VERSION__ })}</span>
|
||||
</footer>
|
||||
<footer class="ft">{t('about.version', { v: __APP_VERSION__ })}</footer>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -278,18 +275,8 @@
|
||||
}
|
||||
.ft {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.ft .offer {
|
||||
color: inherit;
|
||||
}
|
||||
.ft .offer:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -275,7 +275,6 @@ export const en = {
|
||||
'landing.captionTelegram': 'Telegram',
|
||||
'landing.captionVK': 'VK',
|
||||
'landing.captionWeb': 'Web',
|
||||
'landing.offer': 'Public offer',
|
||||
|
||||
'install.title': 'Install the app',
|
||||
'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.',
|
||||
|
||||
@@ -275,7 +275,6 @@ export const ru: Record<MessageKey, string> = {
|
||||
'landing.captionTelegram': 'Telegram',
|
||||
'landing.captionVK': 'VK',
|
||||
'landing.captionWeb': 'Веб-версия',
|
||||
'landing.offer': 'Публичная оферта',
|
||||
|
||||
'install.title': 'Установить приложение',
|
||||
'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.',
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -8,5 +8,5 @@
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts", "src/lib/offer.ts"]
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { resolve } from 'node:path';
|
||||
import { defineConfig, type Plugin } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
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
|
||||
@@ -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
|
||||
// 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`
|
||||
@@ -77,7 +60,6 @@ export default defineConfig(({ mode }) => ({
|
||||
plugins: [
|
||||
svelte(),
|
||||
emitPolyfills(),
|
||||
emitOffer(),
|
||||
injectBootVersion(),
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user