# module-02. Проектная спецификация тестового frontend-сайта > Статус: целевая спецификация реализации MVP; это проектирование, не код. > Канонические источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md). ## 1. Назначение и границы Сайт нужен для ручной, интеграционной и E2E-проверки всех пользовательских сценариев HAN Chat через реальные публичные API. Он остаётся простым по визуальному дизайну, но функционально покрывает guest, OTP/PKCE, bootstrap, UX-сессию, чат, файлы, профиль, realtime и деградации. Сайт не реализует бизнес-решения backend, не обращается к PostgreSQL, Redis, Bitrix24 или S3 постоянными credentials и не подменяет Message Safety. Публичный вопрос отправляется обычным текстовым сообщением. ## 2. Зафиксированный стек - Expo SDK + React Native + TypeScript, web target через Expo Router. - React Query для server state; локальный reducer/state machine для auth и отложенной отправки. - `expo-auth-session`/OIDC Authorization Code Flow with PKCE; парольный flow запрещён. - SecureStore на native; для test web — защищённая browser storage adapter с явным предупреждением о риске XSS. Access token предпочтительно держать в памяти, refresh token — в доступном платформе secure storage. - React Hook Form + schema validation (Zod либо эквивалент). - WebSocket API браузера; REST polling как обязательный fallback. - Playwright для web E2E, Vitest/Jest + Testing Library для unit/component. - Никакого отдельного frontend nginx: production-статику отдаёт единственный корневой nginx. ## 3. Предлагаемая структура ```text frontend-test-site/ app/ _layout.tsx index.tsx auth/callback.tsx dialogs/index.tsx dialogs/[dialogId].tsx profile.tsx diagnostics.tsx src/ api/{client,errors,public,auth,dialogs,attachments,profile}.ts auth/{oidc,pkce,token-store,refresh-single-flight}.ts session/{ux-session,activity}.ts realtime/{socket,polling,reconcile}.ts flows/{deferred-send,bootstrap}.ts components/ config/ accessibility/ tests/{unit,component,contract,e2e}/ app.config.ts package.json ``` ## 4. Runtime state | Состояние | Хранение | Правило | |---|---|---| | access token | память | не логировать, не показывать полностью | | refresh token | secure adapter | очищать при logout/`invalid_grant` | | PKCE verifier/state/nonce | session storage, короткий TTL | одноразовые, проверяются callback | | `ux_session_id`, `last_activity_at` | только память | не localStorage | | `guest_session_id` | локально, опционально | не auth, не посылается как право доступа | | pending text intent | session storage, до терминального send outcome | восстанавливает отправку после OIDC redirect | | pending file intent | память | переносит выбранный `File` с главной в чат без сериализации | | REST cursors | память по dialog | opaque, не парсить | Auth state machine: `guest → authorizing → bootstrapping → authenticated`; при refresh failure — обратно `guest`. UX-сессия независима от Keycloak-сессии. ## 5. Экраны ### 5.1. Главная - загрузка `GET /api/v1/public/app-config` и `/content`; - приветствие, популярные вопросы, textarea, attach button, send; - индикаторы загрузки/ошибки и повтор; - ссылки на чат и профиль (при guest запускают auth только по явному действию); - диагностический badge режима: guest/authenticated, WS/polling, без раскрытия token. Выбор популярного вопроса сразу запускает тот же send flow, что ручной текст. ### 5.2. Согласия и OTP Modal согласий (макет AuthConsent) показывает три блока. В блоке `personal_data` — две ссылки: `document_url` (согласие на обработку ПД) и `privacy_policy_document_url` (политика ПД из `consent.privacy_policy.document_url`). В блоках `user_agreement` и `marketing` — по одной ссылке из `document_url` (`consent.marketing.document_url` для рекламы). Обязательность берётся из `consent.*.required` (`personal_data`/`user_agreement` обычно обязательны, `marketing` — нет). После подтверждения intent остаётся в памяти, начинается OIDC PKCE redirect. OTP вводится на странице/теме Keycloak. В mock mode Keycloak сверяет secret-код; в real mode Keycloak генерирует и локально проверяет OTP, а доставку заказывает в `sms-service` по module-11. Frontend не вызывает `sms-service`/Direct, не получает provider status, service URL/token или mock secret. Resend запускает новое Keycloak action, блокирует double click на время запроса и сообщает, что предыдущий код недействителен (`superseded`). Countdown строится из snapshot challenge (`expires_at`/`otp_ttl_sec`), без hardcoded `6` digits или `0:59`. ### 5.3. Чат - в MVP frontend показывает пользователю один чат с компанией без истории диалогов; - пункт навигации «Чат» вызывает `POST /dialogs`: backend возвращает текущий активный диалог или создаёт новый, после чего frontend открывает `/dialogs/{dialog_id}`; - backend-модель диалогов и `GET /dialogs` сохраняются для будущего возврата истории; - экран чата содержит статус, сообщения, composer и attachment; - сообщения сортируются по `created_at asc`, дубли объединяются по `message_id`; - `waiting_for_company`, `waiting_for_client`, `closed` отображаются русскими подписями; - завершённая беседа readonly; CTA «Продолжить общение» повторно открывает текущий чат через `POST /dialogs`; - промежуточный safety `203` клиенту не показывается: send request остаётся в progress до финального ответа. ### 5.4. Профиль Readonly блок «Личные данные» из `GET /me`; блок «Документы» из `/me/documents`, допускается пустой. Редактирование отсутствует. Для изменения данных — CTA в чат. Download URL запрашивается только после клика и не сохраняется. ### 5.5. Diagnostics (только non-production) Последние безопасные request id, HTTP status, WS state, cursor, время token expiry и UX-session id. Tokens, OTP, PII, тела сообщений и presigned URL не выводятся. ## 6. Startup, auth и bootstrap 1. Немедленно показать guest UI и параллельно загрузить public config/content. 2. Проверить refresh token. При наличии — выполнить silent Refresh Token Grant через single-flight. 3. При успехе определить новую UX-сессию (`cold_start` при новом page lifecycle), вызвать `session-start`, затем загрузить profile/dialogs. 4. При отсутствии/истечении refresh token оставаться guest до protected action. 5. После OTP callback проверить `state`/`nonce`, обменять code с PKCE, вызвать `POST /auth/bootstrap` с согласиями и device metadata. 6. Создать UX-сессию, если её нет, завершить auth-экран и перейти в чат; pending intent отправляется уже экраном чата. Ошибка Message Safety/Bitrix не превращается в ошибку bootstrap. Bootstrap повторяем безопасно после неопределённого сетевого результата. Телефон в body никогда не передаётся. ## 7. UX-сессия - `ux_session_id` и activity timestamp живут только в памяти вкладки. - После JWT `session-start` вызывается с `first_launch`, `cold_start` либо `idle_timeout`. - Idle timeout берётся из app-config; default UI не подменяет server config. - Visibility/focus/user input обновляют activity; возврат после превышения timeout создаёт новую сессию. - Refresh token grant не создаёт новую UX-сессию. - `X-Ux-Session-Id` добавляется ко всем JWT REST-запросам, когда id уже получен. ## 8. HTTP client и заголовки Каждый API-запрос получает `X-Request-ID` (UUID), `traceparent` при активной трассировке и `Accept: application/json`. Protected request получает Bearer token и `X-Ux-Session-Id`. `Idempotency-Key` обязателен для `POST /dialogs` и `POST .../messages`; ключ создаётся один раз на пользовательское действие и сохраняется на retry этого действия. Новый intent получает новый key. Для attachment init/complete повтор соблюдает state/idempotency контракта backend. Единый error envelope маппится по `error.code`, а `request_id` показывается в деталях поддержки. Тело/headers с credentials не логируются. ## 9. Token refresh single-flight - планировать refresh за 60 секунд до `exp`; - один Promise/mutex на refresh; все параллельные запросы ждут его; - на первом `401 unauthorized/token_expired` — один refresh и один replay исходного запроса; - mutating replay использует исходный `Idempotency-Key`; - второй `401` не запускает цикл; - `invalid_grant` очищает tokens, закрывает WS, переводит в guest; - WS auth failure использует тот же single-flight, затем reconnect; - logout отзывает/завершает OIDC-сессию best effort и всегда очищает local secrets. ## 10. Отправка текста 1. Валидировать непустой нормализованный текст и клиентский max length из контракта. 2. Если guest — сохранить intent, consent → OTP → bootstrap → session-start. 3. `POST /dialogs` с idempotency key, сохранить `dialog_id` и перейти на экран чата до отправки. 4. Экран чата выполняет `POST /dialogs/{id}/messages` с отдельным стабильным key. 5. Блокировать повторный click только для того же intent; другие действия не замораживать. 6. На `201` merge `MessageResponse`; на `422 message_blocked` не показывать красную техническую ошибку, а обновить историю с сохранённой backend `company`-репликой; на `503/504` предложить retry с тем же key. 7. Composer показывает счётчик `n/max`, где `max` приходит как `messages.max_text_length` из public app-config; сверх лимита отправка блокируется без обрезки ввода. ## 11. Файловый flow MVP допускает ровно один файл, только allow-list extension+MIME, до 5 МБ или значений app-config. 1. Локальная prevalidation. 2. Создать/reuse dialog. 3. `POST .../attachments/init` с filename, MIME, size. 4. Выполнить прямой `PUT upload_url` с точно выданными `upload_headers`; API domain при этом не используется. 5. Вычислить SHA-256, вызвать `complete`. 6. Отправить file message с `attachment_id` и `sha256:`. Presigned URL не сохраняется и редактируется из диагностик. Abort позволяет отменить PUT; orphan очищает backend. При expiry init выполняется повторно в рамках согласованного состояния. CORS S3 должен разрешать origin сайта, PUT и необходимые headers. ## 12. Realtime и polling Предпочтение — `WSS /api/v1/realtime`, token через согласованный subprotocol; query token допускается только для совместимости и не логируется. - connected → subscribe актуальных dialog ids; - события `message.new`, `message.status`, `dialog.status` merge идемпотентно; - отвечать `pong` на `ping`; - reconnect 1, 2, 4…30 секунд с jitter; - после каждого reconnect делать REST gap reconciliation по последнему cursor; - если WS недоступен более 30 секунд — polling `GET .../messages?after=...`; - polling прекращается после устойчивого WS, но только после reconciliation; - hidden tab снижает polling frequency без нарушения восстановления; - неизвестные event types игнорируются с безопасной метрикой. ## 13. Ошибки и UX | Ситуация | Поведение | |---|---| | offline/network | banner, сохранение intent в памяти, ручной retry | | 400 validation | подсветить поле; не retry автоматически | | 401 | single-flight refresh; при провале guest | | 403 consents | обновить config, повторить consent flow | | 404 | безопасное «ресурс недоступен», обновить список | | 409 idempotency | остановить retry, показать request id | | 422 blocked | нейтральное сообщение, контент не отправлен | | 429 | countdown по `Retry-After` | | 503/504 | зависимость недоступна; retry с тем же key | | OTP invalid/expired/superseded/limited | показать соответствующий безопасный Keycloak UX; generic order unavailable не раскрывает provider | | S3 PUT error | оставить attachment intent, предложить повтор | | WS failure | polling badge, чат остаётся usable | Skeleton/empty/error states обязательны для каждого data screen. Никаких optimistic «delivered» до `201`. ## 14. Конфигурация и сборка Public build-time env содержит только URL/realm/client id: ```text EXPO_PUBLIC_API_BASE_URL=https://tohin.ru EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth EXPO_PUBLIC_KEYCLOAK_REALM=han-chat EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend EXPO_PUBLIC_APP_ENV=production-like ``` Redirect URI и allowed origins фиксируются в Keycloak/nginx. Service tokens, S3 keys и mock OTP code во frontend env запрещены. Бизнес-конфиг приходит через `/public/app-config`, тексты — `/public/content`. Production: статический export монтируется в корневой nginx, `try_files $uri /index.html`; hashed assets immutable, `index.html` no-cache/revalidate. Local dev: Expo dev server, опциональный proxy корневого nginx через `FRONTEND_DEV_PROXY_ENABLED=true`. ## 15. Доступность - WCAG 2.1 AA как цель; полная keyboard navigation и видимый focus. - Семантические headings/landmarks, labels и error descriptions. - Modal: focus trap, возврат focus, Escape только если не нарушает обязательный flow. - Live region для новых сообщений и статусов без повторного озвучивания всей ленты. - Контраст, zoom 200%, reduced motion, touch targets не менее 44×44 CSS px. - Статусы не кодируются одним цветом; файлы имеют доступные имена и progress. - OTP поля поддерживают paste/autocomplete, но не логируют значение. ## 16. Тестовая матрица ### Unit/component - auth state machine, PKCE callback state/nonce; - refresh scheduler/single-flight/concurrent 401; - UX idle boundary; - idempotency key reuse; - error mapping/redaction; - text/file union и file validation; - WS merge, duplicate, reconnect и poll switch; - accessibility scans ключевых экранов. ### Contract/integration - DTO соответствует `api-backend/openapi.yaml`; - public cache/ETag; - bootstrap без phone body; - dialog 200/201; - message 201/422/429/503/504; - presigned PUT headers/checksum/expiry; - profile/documents readonly; - WS события и REST reconciliation. ### E2E | Сценарий | Варианты | |---|---| | guest | просмотр public content; write закрыт | | first send | manual/popular → consents → mock и real OTP → delivered | | return | valid refresh без OTP; expired refresh с OTP | | text safety | allow, block, pending-to-final, timeout | | file | PDF/image allow, deny, wrong MIME/size/checksum, expired URL | | realtime | message, status, close, reconnect, polling fallback | | concurrency | два send click, несколько 401, две вкладки | | profile | filled/null fields, empty documents, download failure | | security | XSS text, token absence in logs/storage diagnostics | | OTP resend | double click; старый код `superseded`; новый код; snapshot countdown; order unavailable | Browsers: Chromium, Firefox, WebKit; viewport desktop/mobile. В CI используются Keycloak mock mode и локальный mock/WireMock Direct. Отдельный sandbox Direct не предполагается; provider smoke выполняется только ops на контролируемом номере. ## 17. Definition of Done - все экраны и flows выше реализованы на русском; - guest не вызывает protected write; - PKCE/OTP, silent refresh, bootstrap и pending intent проверены E2E; - UX-session создаётся и передаётся строго по правилам; - idempotency и refresh single-flight выдерживают concurrency; - text/file lifecycle работает через presigned PUT; - WS и polling не оставляют gap; - profile readonly и documents empty state реализованы; - CSP/CORS совместимы без unsafe token practices; - отсутствуют secrets, PII, message body и URLs в логах; - accessibility checks и keyboard сценарии проходят; - unit/component/contract/E2E matrix зелёная; - production static и dev proxy режимы проверены через единственный nginx. - frontend bundle/config/analytics не содержит raw OTP, `sms-service`/Direct credentials или provider status; resend/expiry/limits проверены для real-mode контракта. ## 18. Решения, допущения и TBD **Решения:** Expo/TypeScript; server state через React Query; auth state machine; WS best effort + обязательная REST reconciliation; никаких optimistic delivered. **Допущения:** test site использует те же API и Keycloak realm contracts, что мобильные клиенты; locale MVP — `ru`; browser secure storage не эквивалентен OS Keychain, поэтому CSP и отсутствие сторонних scripts обязательны. **TBD:** - F1: окончательный Keycloak browser adapter и token rotation policy; - F2: точные max lengths и WS limits после OpenAPI; - F3: web storage policy refresh token перед production security review; - F4: окончательный DTO app-config (G10); - F5: WS `event_id`/protocol version (TBD module-01/G11); - F6: продуктовые тексты всех error states по мнемоникам.