diff --git a/docs/adr/ADR-003-auth-pattern.md b/docs/adr/ADR-003-auth-pattern.md index 3e278c8..4fb9617 100644 --- a/docs/adr/ADR-003-auth-pattern.md +++ b/docs/adr/ADR-003-auth-pattern.md @@ -1,35 +1,163 @@ -# ADR-003: Паттерн аутентификации пользователей +# ADR-003: Паттерн аутентификации и хранения идентификатора **Дата:** 2026-03-20 -**Статус:** Stub (подлежит заполнению) -**Автор:** — +**Статус:** Accepted +**Автор:** Architect Agent (Kin pipeline, BATON-003) +**Решения:** #1013, #1015, #1024, #1025 --- ## Контекст -_Описание контекста — предстоит заполнить._ +Baton — PWA экстренного сигнала для 300–400 пользователей из разных стран. Требования к аутентификации: + +1. **Минимальное трение:** пользователь регистрируется один раз (имя), далее никаких логинов +2. **Stateless на сервере:** нет серверных сессий, JWT или token refresh +3. **Персистентность:** UUID сохраняется между перезапусками браузера (желательно бессрочно) +4. **Кроссплатформенность:** Android Chrome, iOS Safari (включая приватный режим), Desktop +5. **Нет чувствительных данных:** идентификатор не даёт доступа ни к чему — утечка UUID безвредна --- ## Варианты -_Описание вариантов — предстоит заполнить._ +### Вариант A: JWT + refresh token + +**Плюсы:** +- Стандартный подход, проверенный в enterprise +- Поддержка ролей и permissions + +**Минусы:** +- Требует серверную логику refresh (endpoint, хранение revoked tokens) +- Избыточен: в Baton нет ролей, нет protected resources +- bcrypt блокирует asyncio event loop (#1004) — нужен `run_in_executor` +- Добавляет ~200 строк серверного кода без пользы для задачи + +### Вариант B: UUID v4 в localStorage (stateless) + +**Плюсы:** +- `crypto.randomUUID()` — криптографически стойкий, 122 бита энтропии +- Поддержка: Chrome 92+, Firefox 95+, Safari 15.4+ (97%+ глобально) +- Нет серверного состояния — UUID генерируется клиентом +- Один endpoint `/api/register` — idempotent, UUID как уникальный ключ +- ~15 строк клиентского кода + +**Минусы:** +- UUID привязан к устройству, не к человеку (переход на другое устройство = новый UUID) +- iOS Safari приватный режим: `localStorage.setItem()` бросает `SecurityError` (#1015) +- Очистка кеша / 7-дневная очистка iOS = потеря UUID → новая регистрация + +### Вариант C: Session cookie (httpOnly) + +**Плюсы:** +- Автоматическая отправка с каждым запросом +- httpOnly защищает от XSS + +**Минусы:** +- Требует серверную сессию или signed cookie +- Cookie подчиняются SameSite ограничениям +- iOS Safari aggressively очищает 3rd-party cookies +- Добавляет серверную логику без выгоды для stateless модели --- ## Решение -_Решение — предстоит заполнить._ +**Выбран Вариант B: UUID v4 в localStorage с цепочкой fallback** + +Цепочка хранения (#1025): + +``` +localStorage → sessionStorage → in-memory object +``` --- ## Обоснование -_Обоснование — предстоит заполнить._ +1. **UUID v4 достаточен для идентификации (#1013).** В Baton нет protected resources. UUID — это просто "кто нажал кнопку". Утечка UUID безвредна — атакующий может отправить ложный сигнал, но это решается на уровне модерации Telegram-группы, а не криптографии. + +2. **localStorage проверяется реальной записью, не `typeof` (#1024):** + ```javascript + function isStorageAvailable(storage) { + try { + const key = '__baton_test__'; + storage.setItem(key, '1'); + storage.removeItem(key); + return true; + } catch (e) { + return false; + } + } + ``` + `typeof localStorage !== 'undefined'` вернёт `true` в iOS private mode, но `.setItem()` бросит `SecurityError`. Только реальная запись выявляет проблему. + +3. **Цепочка fallback (#1025) гарантирует работу на любой платформе:** + - **localStorage** (нормальный режим): UUID живёт бессрочно* + - **sessionStorage** (iOS private mode fallback): UUID живёт до закрытия вкладки + - **in-memory** (крайний случай, CSP/iframe restrictions): UUID живёт до перезагрузки страницы + + *На iOS — до 7 дней неактивности или ручной очистки. + +4. **JWT отклонён** — избыточная сложность. Baton не имеет ролей, permissions, или данных требующих авторизации. `run_in_executor` для bcrypt (#1004) — это overhead ради несуществующей потребности. + +5. **Session cookies отклонены** — серверное состояние противоречит stateless архитектуре. SameSite и iOS cookie policies создают edge cases без пользы. + +--- + +## Реализация (спецификация для dev-агента) + +### Клиентская сторона (app.js) + +```javascript +let _memoryStore = {}; + +function getStorage() { + if (isStorageAvailable(localStorage)) return localStorage; + if (isStorageAvailable(sessionStorage)) return sessionStorage; + return { + getItem: (k) => _memoryStore[k] || null, + setItem: (k, v) => { _memoryStore[k] = v; }, + removeItem: (k) => { delete _memoryStore[k]; } + }; +} + +function getOrCreateUserId() { + const storage = getStorage(); + let id = storage.getItem('baton_user_id'); + if (!id) { + id = crypto.randomUUID(); + storage.setItem('baton_user_id', id); + } + return id; +} +``` + +### Серверная сторона (backend) + +- `POST /api/register` принимает `{uuid, name}`, возвращает `{user_id, uuid}` +- Idempotent: `INSERT OR IGNORE INTO users (uuid, name)` — повторный вызов с тем же UUID возвращает существующую запись +- UUID в таблице `users` — UNIQUE constraint + +### Поток первого визита + +``` +1. PWA открыт → getOrCreateUserId() → UUID сгенерирован +2. Показать форму ввода имени +3. POST /api/register {uuid, name} → 200 OK +4. UUID сохранён в storage → следующий визит: UUID уже есть, форма не показывается +``` --- ## Последствия -_Последствия — предстоит заполнить._ +1. **UUID = устройство, не человек.** Один пользователь на двух устройствах = два UUID. Это приемлемо: цель — знать ИМЯ нажавшего, а не объединять профили. + +2. **Потеря UUID → повторная регистрация.** При очистке данных или 7-дневной iOS-очистке пользователь получит новый UUID и должен будет заново ввести имя. Частота: низкая (пользователи экстренного приложения открывают его при установке и при экстренных ситуациях). + +3. **Приватный режим = временный UUID.** Это ожидаемое поведение, не баг. Пользователь в private mode осознанно отказывается от персистентности. + +4. **Нет миграции данных.** UUID не переносится между устройствами. Если потребуется в будущем — добавить Telegram user ID как secondary identifier (уже есть endpoint `/api/webhook/telegram` для `/start`). + +5. **crypto.randomUUID() требует Secure Context.** HTTPS обязателен. На HTTP `crypto.randomUUID` вернёт `undefined` в некоторых браузерах → дополнительный fallback через `URL.createObjectURL(new Blob()).slice(-36)` НЕ нужен, т.к. HTTPS обязателен для PWA (#1011). diff --git a/docs/ux_spec.md b/docs/ux_spec.md new file mode 100644 index 0000000..10488d6 --- /dev/null +++ b/docs/ux_spec.md @@ -0,0 +1,179 @@ +# UX Specification: Baton PWA + +**Версия:** 1.0 +**Дата:** 2026-03-20 +**Статус:** Ready for Frontend + +--- + +## Обзор + +Baton — PWA экстренного сигнала. Два ключевых экрана: + +1. **Registration** — одноразовый экран при первом запуске: пользователь вводит имя для идентификации в Telegram-уведомлениях. +2. **Main** — постоянный рабочий экран: одна большая кнопка вызова. + +Интерфейс должен быть интуитивно понятен без знания языка — см. раздел «Методология usability-тестирования». + +--- + +## Wire-frames + +### Экран: Registration + +``` ++--------------------------------------------------+ +| [U] [WiFi] | +| | +| | +| | +| +---------------------------+ | +| | Введите ваше имя | | +| +---------------------------+ | +| | +| +---------------------------+ | +| | Подтвердить | | +| +---------------------------+ | +| | +| | ++--------------------------------------------------+ + +Элементы: + [U] — иконка пользователя, верхний левый угол + [WiFi] — иконка wi-fi соединения, верхний правый угол + [ Введите ваше имя ] — однострочное поле ввода, центр экрана + [ Подтвердить ] — кнопка подтверждения, под полем ввода +``` + +--- + +### Экран: Main + +``` ++--------------------------------------------------+ +| [U] [WiFi] | +| | +| | +| +----------------------------------------+ | +| | | | +| | | | +| | | | +| | ВЫЗОВ | | +| | | | +| | ( >= 60vmin ) | | +| | | | +| | | | +| +----------------------------------------+ | +| | ++--------------------------------------------------+ + +Элементы: + [U] — иконка пользователя, верхний левый угол + [WiFi] — иконка wi-fi соединения, верхний правый угол + ( ВЫЗОВ ) — кнопка вызова, центр экрана, + минимальный размер: 60vmin × 60vmin + (занимает большую часть рабочей области) +``` + +--- + +## Поведение элементов + +### Иконка [U] (пользователь) + +- Отображает инициалы или иконку профиля текущего пользователя +- Tap → открыть информацию о текущем пользователе (v2) +- В v1: не интерактивна, только индикатор + +### Иконка [WiFi] + +- Показывает статус сети: полный сигнал / нет соединения +- Обновляется по `navigator.onLine` события +- Цвет: зелёный (online) / серый (offline) + +### Поле ввода имени (Registration) + +- Placeholder: «Введите ваше имя» +- Максимум 100 символов +- Обязательное поле: кнопка «Подтвердить» неактивна при пустом поле +- После подтверждения: имя сохраняется через `POST /api/register` + +### Кнопка «Подтвердить» (Registration) + +- Неактивна (disabled) при пустом поле ввода +- Активна при наличии хотя бы одного символа +- Tap → `POST /api/register` → переход на экран Main + +### Кнопка «ВЫЗОВ» (Main) + +- Размер: минимум 60vmin × 60vmin +- Форма: круг или скруглённый прямоугольник +- Tap → `POST /api/signal` с геолокацией (если разрешена) +- Состояния: + - Default: красный / яркий + - Pressed: тёмнее, scale(0.96) + - Sending: spinner или пульсация + - Success: краткая зелёная анимация (≤1 сек) + - Error: встроенное сообщение «Нет подключения» (при offline) + +--- + +## Методология usability-тестирования + +**Обязательный критерий QA-фазы** (decision #1035). + +### Параметры теста + +| Параметр | Значение | +|---------|---------| +| Тестировщики | 3 человека | +| Знание языка интерфейса | Отсутствует (не знают язык UI) | +| Попыток | 1 (первая и единственная) | +| Подсказки | Запрещены | + +### Ключевой сценарий + +1. Открыть приложение (экран Registration) +2. Ввести своё имя в форму +3. Нажать кнопку подтверждения +4. На экране Main — нажать кнопку вызова +5. Дождаться подтверждения отправки + +### Критерий прохождения + +Все 3 тестировщика завершают сценарий с первой попытки без подсказок. + +### Критерий провала (блокирующий дефект) + +Хотя бы один тестировщик из трёх: +- Не нашёл поле ввода имени +- Не понял, как подтвердить ввод +- Не нашёл кнопку вызова на главном экране +- Не понял, что сигнал отправлен (нет обратной связи) + +При провале: UI-дизайн возвращается на доработку. Выпуск Frontend не разрешён. + +### Проведение теста + +- Тестировщики тестируют независимо, не наблюдая за другими +- Засекается время прохождения каждого шага +- Фиксируются: колебания, ошибочные нажатия, паузы > 3 сек + +--- + +## Константы дизайна + +| Параметр | Значение | +|---------|---------| +| Кнопка вызова min-size | 60vmin × 60vmin | +| Цвет кнопки вызова | #FF0000 (или близкий красный) | +| Фон приложения | #000000 | +| Тема | `theme_color: #ff0000` (из manifest.json) | + +--- + +## Связанные документы + +- `docs/tech_report.md` — технический отчёт, PWA-ограничения, auth +- `docs/backend_spec.md` — API контракты `/api/register`, `/api/signal` +- `docs/adr/ADR-003-auth-pattern.md` — UUID stateless auth