# 06 · Настройки и PWA (web-push)

Оба механизма — второй шаг многопользовательской волны (2026-10-03):
- **Настройки** — редактируемые параметры Synapse: дефолт из `.env`, оверрайды через админку (хранятся в БД, применяются без перезапуска контейнера). Секреты — write-only: значение записывается в БД, но никогда не возвращается API.
- **PWA + web-push** — SPA устанавливается как приложение (`manifest.webmanifest` + service worker `sw.js`), канал `push` доставляет уведомления в браузеры пользователя через VAPID web-push.

---

## Настройки: реестр, хранение, API

### Реестр

`app/settings_registry.py` — единственное место, где описан редактируемый параметр:

```python
SettingDef(key, section, label, kind, options=(), help="")
```

`kind`: `str | int | bool | secret` (секрет — `password`-поле UI). Сейчас в реестре:

| section | ключ | kind | назначение |
|---|---|---|---|
| routing | `routing_match_mode` | str(all/first) | режим матчинга правил |
| routing | `dedup_window_seconds` | int 1..10^6 | окно дедупликации |
| routing | `retention_days` | int 0..366 | окно ретеншна (0 — выкл) |
| push | `vapid_public_key` | str | публичный VAPID-ключ |
| push | `vapid_private_key` | **secret** | приватный VAPID-ключ |
| push | `push_subject` | str | контакты отправителя (`mailto:…`) |
| telegram | `telegram_bot_token` | **secret** | токен бота (канал #29 не готов) |
| smtp | `smtp_host` / `smtp_port` / `smtp_user` | str/int | SMTP-конфиг (канал #29 не готов) |
| smtp | `smtp_password` | **secret** | пароль SMTP |
| smtp | `smtp_from` | str | From-адрес |

Новая настройка = запись в `EDITABLE`; чтения — в `settings_store.get_setting(db, key)`. Ключи реестра, совпадающие с полями `app.config.Settings` (`routing_match_mode`, `dedup_window_seconds`, `retention_days`), получают дефолт из `.env`; ключи, которых в config нет (`vapid_*`, `smtp_*`, `telegram_*`) задаются только через UI.

### Хранение и резолв

- Оверрайды — таблица `app_settings(key PK, value, updated_at)`; строка — установить, удалить строку — вернуть дефолт.
- `settings_store.get_setting(db, key)` — env-дефолт + оверрайд, коэрсия по типу реестра (int → `int(raw)`, bool → `true/1`). Без кэша: чтения редкие (приём, ingest, expire-прогон), один SELECT по PK.
- `settings_store.get_setting_row(db, key)` — сырое значение (для секретов в воркере: send_push).
- `set_setting(db, key, None)` — удалить оверрайд.

### API (под require_admin)

- `GET /api/v1/admin/settings` → список из реестра: `{key, section, label, kind, help, options, value, set}`. Для секрета `value` всегда `null`, факт наличия дефолта/оверрайда — `set`.
- `PUT /api/v1/admin/settings` body `{"values": {key: "строка" | null}}`:
  - неизвестный ключ → `422`;
  - int вне диапазона / enum не из options → `422`;
  - секрет: `""` — «не менять» (UI не отправляет пустое), непустая строка — set, `null` — сброс;
  - не-секрет `null` — вернуть дефолт `.env`.

Секреты читает только воркер (send_push) — API их не отдаёт. Это осознанное отступление от правила «секретам не жить в БД»: секреты должны быть редактируемы из UI, хранилище БД write-only с ограничением чтения уровнем кода (владелец согласил 2026-10-03). Прочие секреты (gauth client secret и т.п.) — по-прежнему только `.env` + gnexus-creds.

### UI

`/settings` — один раздел для всех ролей: у пользователя личный блок web-push, у `admin+` ещё секции конфига (Маршрутизация / Web Push / Telegram / SMTP). Секрет — `password`-поле с плейсхолдером «••••• задано — оставить пустым, чтобы не менять»; кнопка «Перебить дефолт» шлёт только изменённые значения (пустое поле открытой настройки = `null` = сброс оверрайда).

---

## PWA и web-push

### Оболочка

- `frontend/public/manifest.webmanifest` — name/short_name Synapse, `start_url: "/"`, `display: standalone`, theme `#16161E`, иконки 192/512 (+512 maskable) из `gnexus-mark.svg`.
- `frontend/public/sw.js` — pass-through fetch (никаких оффлайн-кэшей: API-приложение), обработчики `push` (showNotification, payload JSON `{title, body, event_id, url}`) и `notificationclick` (фокус существующего окна / openWindow на `url`).
- `index.html` — `<link rel="manifest">` + `theme-color`; файлы отдаёт spa_fallback из dist контейнера API.
- Регистрация SW — в `main.js` после mount; ошибки — console.warn, приложение не блокируется.

### Подписка (самоопределение адресата)

Решает половину открытого вопроса №3: привязка «user_id ↔ адрес идентичности канала» для push создается самим пользователем — браузер подписывается, Synapse хранит только `(user_id, endpoint, keys)`.

Каналы API (`app/api/me_routes.py`, любой аутентифицированный):

- `GET /api/v1/me/push` → `{configured, public_key}` (public из настроек; `configured=false` — VAPID не заданы);
- `GET /api/v1/me/push/subscriptions` — свои устройства;
- `POST /api/v1/me/push/subscriptions` `{endpoint, keys{p256dh,auth}, ua}` — 201, upsert по endpoint (endpoint уникален: подписка «переезжает» новому пользователю, если браузер сменил аккаунт);
- `DELETE /api/v1/me/push/subscriptions/{id}` — 204 (только свой, чужой 404).

Клиентская логика — `frontend/src/push.js`: разрешение браузера, `pushManager.subscribe` с `applicationServerKey`, база64url → Uint8Array.

### Канал `push`

- `CHANNELS += "push"` — в правило ставится действие `{"channel": "push"}` (цель не задаётся — как у `user`).
- Адресат — `payload.user_id`: без валидного sub → `skipped` с причиной в `/admin/deliveries`; без VAPID или без подписок → `pending` с ошибкой и ретраями.
- Воркер (`app/worker/senders.py: send_push`): на каждую подписку `pywebpush(..., vapid_private_key, vapid_claims={sub: push_subject})`; ответ 404/410 (браузер отписался/endpoint истёк) — подписка удаляется; успех — когда все живые подписки отправлены (доставок нет — тоже ошибка аудитора, не тихий пропуск).

### Как задать VAPID (рецепт)

Генерация пары P-256-ключей и значения для UI (проверено в контейнере):

```bash
python3 - <<'PY'
import base64
from cryptography.hazmat.primitives.serialization import (
    Encoding, NoEncryption, PublicFormat, PrivateFormat,
)
from py_vapid import Vapid

def b64u(b):
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

v = Vapid()
v.generate_keys()
print("public :", b64u(v.public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)))
print("private:", b64u(v.private_key.private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption())))
PY
```

`public` (base64url без-padding) — в «Настройки → Web Push → VAPID public key», `private` (DER в base64url) — в VAPID private key (write-only); воркер передаёт её через `pywebpush` → `Vapid.from_string` (raw/DER в base64url — сам pywebpush PEM-строки в этом формате не разбирает). `push_subject` — `mailto:you@gnexus.space`.

> ⚠️ Смена приватного VAPID-ключа инвалидирует все подписки браузеров — после смены пользователи перезакажут подписку тумблером в разделе «Настройки».

---

## Проверено

- `/tmp/setpush.py` (47 проверок, ALL OK): маска секретов, PUT-валидация, эффективные чтения оверрайдов, `me/push*`, CRUD подписок (upsert/чужой 404/смена владельца endpoint), routing push (skipped/pending), `send_push` (мок: 3 подписки delivered, 410-подписка удаляется).
- Регресс: `/tmp/mecheck.py`, `/tmp/retcheck.py` — ALL OK.
- Puppeteer `/tmp/navcheck/setpush.js` (17 проверок, ALL OK): user — личный блок и отсутствие админ-секций; user при не-настроенном VAPID — карточка «Сервер ещё не настроен»; admin — секции, маска секретов, GnSelect; nav-пункт «Настройки» у обеих ролей.