# DESIGN_BATON008 — Регистрационный flow с Telegram-апрувом ## Flow диаграмма ``` Пользователь Backend Telegram PWA / Service Worker | | | | |-- POST /api/auth/-------->| | | | register | | | | {email,login,pwd,push} | | | | |-- validate input | | | |-- hash password (PBKDF2) | | | |-- INSERT registrations | | | | (status=pending) | | |<-- 201 {status:pending} --| | | | |-- create_task ─────────────>| | | | send_registration_ | | | | notification() | | | | | | | | [Admin видит сообщение с кнопками] | | | [✅ Одобрить / ❌ Отклонить] | | | | | | |<-- POST /api/webhook/ -----| (callback_query) | | | telegram | | | |-- parse callback_data | | | |-- UPDATE registrations | | | | SET status='approved' | | | |-- answerCallbackQuery ─────>| | | |-- editMessageText ─────────>| | | |-- create_task | | | | send_push() ─────────────────────────────────────>| | | | [Push: "Одобрен!"] | |<-- 200 {"ok": True} ------| | | ``` ## API контракт ### POST /api/auth/register **Request body:** ```json { "email": "user@example.com", "login": "user_name", "password": "securepass", "push_subscription": { "endpoint": "https://fcm.googleapis.com/fcm/send/...", "keys": { "p256dh": "BNcR...", "auth": "tBHI..." } } } ``` `push_subscription` — nullable. Если null, push при одобрении не отправляется. **Validation:** - `email`: формат email (Pydantic EmailStr или regex `[^@]+@[^@]+\.[^@]+`) - `login`: 3–30 символов, `[a-zA-Z0-9_-]` - `password`: минимум 8 символов - `push_subscription`: nullable object **Response 201:** ```json {"status": "pending", "message": "Заявка отправлена на рассмотрение"} ``` **Response 409 (дубль email или login):** ```json {"detail": "Пользователь с таким email или логином уже существует"} ``` **Response 429:** rate limit (через существующий `rate_limit_register` middleware) **Response 422:** невалидные поля (Pydantic автоматически) --- ### POST /api/webhook/telegram (расширение) Существующий эндпоинт. Добавляется ветка обработки `callback_query`: **Входящий update (approve):** ```json { "callback_query": { "id": "123456789", "data": "approve:42", "message": { "message_id": 777, "chat": {"id": 5694335584} } } } ``` **Поведение при `approve:{id}`:** 1. `UPDATE registrations SET status='approved' WHERE id=?` 2. Fetch registration row (для получения login и push_subscription) 3. `answerCallbackQuery(callback_query_id)` 4. `editMessageText(chat_id, message_id, "✅ Пользователь {login} одобрен")` 5. Если `push_subscription IS NOT NULL` → `create_task(send_push(...))` 6. Вернуть `{"ok": True}` **Поведение при `reject:{id}`:** 1. `UPDATE registrations SET status='rejected' WHERE id=?` 2. `answerCallbackQuery(callback_query_id)` 3. `editMessageText(chat_id, message_id, "❌ Пользователь {login} отклонён")` 4. Push НЕ отправляется 5. Вернуть `{"ok": True}` --- ## SQL миграция ```sql -- В init_db(), добавить в executescript: CREATE TABLE IF NOT EXISTS registrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, login TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', push_subscription TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now')) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_registrations_email ON registrations(email); CREATE UNIQUE INDEX IF NOT EXISTS idx_registrations_login ON registrations(login); CREATE INDEX IF NOT EXISTS idx_registrations_status ON registrations(status); ``` Таблица создаётся через `CREATE TABLE IF NOT EXISTS` — backward compatible, не ломает существующие БД. --- ## Список изменяемых файлов | Файл | Тип изменения | Суть | |------|--------------|------| | `backend/db.py` | Modify | Добавить таблицу `registrations` в `init_db()` + 3 функции CRUD | | `backend/config.py` | Modify | Добавить `ADMIN_CHAT_ID`, `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY`, `VAPID_CLAIMS_EMAIL` | | `backend/models.py` | Modify | Добавить `PushKeys`, `PushSubscription`, `AuthRegisterRequest`, `AuthRegisterResponse` | | `backend/telegram.py` | Modify | Добавить `send_registration_notification()`, `answer_callback_query()`, `edit_message_text()` | | `backend/main.py` | Modify | Добавить `POST /api/auth/register` + callback_query ветку в webhook | | `backend/push.py` | **New** | Отправка Web Push через pywebpush | | `requirements.txt` | Modify | Добавить `pywebpush>=2.0.0` | | `tests/test_baton_008.py` | **New** | Тесты для нового flow | **НЕ трогать:** `backend/middleware.py`, `/api/register`, `users` таблица. **Замечание:** CORS `allow_headers` уже содержит `Authorization` в `main.py:122` — изменение не требуется. --- ## Интеграционные точки с существующим кодом ### 1. `_hash_password()` в `main.py` Функция уже существует (строки 41–48). Dev agent должен **переиспользовать её напрямую** в новом endpoint `POST /api/auth/register`, не дублируя логику. ### 2. `rate_limit_register` middleware Существующий middleware из `backend/middleware.py` может быть подключён к новому endpoint как `Depends(rate_limit_register)` — тот же ключ `reg:{ip}`, та же логика. ### 3. `telegram.send_message()` — не модифицировать Существующая функция использует `config.CHAT_ID` для SOS-сигналов. Для регистрационных уведомлений создаётся отдельная функция `send_registration_notification()`, которая использует `config.ADMIN_CHAT_ID`. Это разделяет два потока уведомлений. ### 4. Webhook handler (строки 223–242 в `main.py`) Добавляется ветка в начало функции (до `message = update.get("message", {})`): ```python callback_query = update.get("callback_query") if callback_query: asyncio.create_task(_handle_callback_query(callback_query)) return {"ok": True} ``` Существующая логика `/start` остаётся нетронутой. ### 5. `lifespan` в `main.py` Никаких изменений — VAPID-ключи не требуют startup validation (unlike BOT_TOKEN), так как их инвалидация некритична для работы сервиса в целом. --- ## Спецификация новых компонентов ### `backend/db.py` — 3 новые функции ``` create_registration(email, login, password_hash, push_subscription) -> dict | None INSERT INTO registrations ... ON CONFLICT → raise aiosqlite.IntegrityError (caller catches → 409) Returns: {"id", "email", "login", "created_at"} get_registration_by_id(reg_id: int) -> dict | None SELECT id, email, login, status, push_subscription FROM registrations WHERE id=? update_registration_status(reg_id: int, status: str) -> dict | None UPDATE registrations SET status=? WHERE id=? Returns registration dict or None if not found ``` ### `backend/config.py` — новые переменные ```python ADMIN_CHAT_ID: str = os.getenv("ADMIN_CHAT_ID", "5694335584") VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "") VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "") VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "") ``` Все optional (не `_require`) — отсутствие VAPID только отключает Web Push, не ломает сервис. ### `backend/models.py` — новые Pydantic модели ```python class PushKeys(BaseModel): p256dh: str auth: str class PushSubscription(BaseModel): endpoint: str keys: PushKeys class AuthRegisterRequest(BaseModel): email: str = Field(..., pattern=r'^[^@\s]+@[^@\s]+\.[^@\s]+$') login: str = Field(..., min_length=3, max_length=30, pattern=r'^[a-zA-Z0-9_-]+$') password: str = Field(..., min_length=8) push_subscription: Optional[PushSubscription] = None class AuthRegisterResponse(BaseModel): status: str message: str ``` ### `backend/telegram.py` — 3 новые функции ``` send_registration_notification(login, email, reg_id, created_at) -> None POST sendMessage с reply_markup=InlineKeyboardMarkup chat_id = config.ADMIN_CHAT_ID Swallows все ошибки (decision #1215) answer_callback_query(callback_query_id: str, text: str = "") -> None POST answerCallbackQuery Swallows все ошибки edit_message_text(chat_id: str | int, message_id: int, text: str) -> None POST editMessageText Swallows все ошибки ``` Все три используют тот же паттерн retry (3 попытки, 429/5xx) что и `send_message()`. ### `backend/push.py` — новый файл ``` send_push(subscription_json: str, title: str, body: str) -> None Парсит subscription_json → dict webpush( subscription_info=subscription_dict, data=json.dumps({"title": title, "body": body, "icon": "/icon-192.png"}), vapid_private_key=config.VAPID_PRIVATE_KEY, vapid_claims={"sub": f"mailto:{config.VAPID_CLAIMS_EMAIL}"} ) Если VAPID_PRIVATE_KEY пустой → log warning, return (push disabled) Swallows WebPushException и все прочие ошибки ``` --- ## Edge Cases и решения | Кейс | Решение | |------|---------| | Email уже зарегистрирован | `IntegrityError` → HTTP 409 | | Login уже занят | `IntegrityError` → HTTP 409 | | Rejected пользователь пытается зарегистрироваться заново | 409 (статус не учитывается — оба поля UNIQUE) | | push_subscription = null при approve | `if reg["push_subscription"]: send_push(...)` — skip gracefully | | Истёкший/невалидный push endpoint | pywebpush raises → `logger.warning()` → swallow | | Двойной клик Одобрить (admin кликает дважды) | UPDATE выполняется (idempotent), editMessageText может вернуть ошибку (уже отредактировано) → swallow | | reg_id не существует в callback | `get_registration_by_id` returns None → log warning, answerCallbackQuery всё равно вызвать | | VAPID ключи не настроены | Push не отправляется, log warning, сервис работает | | Telegram недоступен при регистрации | Fire-and-forget + swallow — пользователь получает 201, уведомление теряется | --- ## Решения по open questions (из context_packet) **VAPID ключи не сгенерированы:** Dev agent добавляет в README инструкцию по генерации: ```bash python -c "from py_vapid import Vapid; v = Vapid(); v.generate_keys(); print(v.private_key, v.public_key)" ``` Ключи добавляются в `.env` вручную оператором перед деплоем. **Повторный approve/reject:** Операция idempotent — UPDATE всегда выполняется без проверки текущего статуса. EditMessageText вернёт ошибку при повторном вызове — swallow. **Service Worker:** Фронтенд вне скоупа этого тикета. Backend отправляет корректный Web Push payload — обработка на стороне клиента. **Login после approve:** Механизм авторизации не входит в BATON-008. Регистрация — отдельный flow от аутентификации.