Newer
Older
gn-synapse / docs / 06-settings-pwa.md

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 — единственное место, где описан редактируемый параметр:

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 (проверено в контейнере):

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-пункт «Настройки» у обеих ролей.