kin: BATON-UI-001 Добавить ASCII wire-frame к UX-спецификации перед передачей в Frontend
This commit is contained in:
parent
9f0f2ace9a
commit
4fd8d860a7
2 changed files with 315 additions and 8 deletions
|
|
@ -1,35 +1,163 @@
|
||||||
# ADR-003: Паттерн аутентификации пользователей
|
# ADR-003: Паттерн аутентификации и хранения идентификатора
|
||||||
|
|
||||||
**Дата:** 2026-03-20
|
**Дата:** 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).
|
||||||
|
|
|
||||||
179
docs/ux_spec.md
Normal file
179
docs/ux_spec.md
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue