# Cardy — техническое задание, версия 2 Приложение для хранения скидочных карт: код (штрихкод или 2D-код) + тип кода + название + цвет + опциональное лого + опциональные картинки (фото/скриншоты). Весь пользовательский интерфейс (серверный и клиентский) — на английском, только тёмная тема (коды на экране — чёрным по белому, чтобы сканировались). Вопросы деплоя (Dockerfile, docker-compose, конфиги прокси) в задание не входят. ## 1. Структура репозитория pnpm-монорепо, TypeScript, ESM. | Каталог | Пакет | Назначение | |---|---|---| | `shared/` | `@cardy/shared` | Общие типы, схемы валидации (zod), список типов кодов, соответствие типов кодов `bwip-js`, формат deep link | | `server-be/` | `@cardy/server-be` | Бэкенд: Fastify + TS | | `server-fe/` | `@cardy/server-fe` | Административный веб-UI: React + Vite + TS | | `mobile-client/` | `@cardy/mobile-client` | Клиент: React + Vite + TS, PWA + Capacitor (Android) | ## 2. Процессы, порты и прокси Три отдельных процесса за одним реверс-прокси (домен для разработки — `https://dev.aggtaa.com`): | Приложение | Порт | Публичный адрес | Аутентификация на прокси | |---|---|---|---| | `server-fe` (Vite dev/preview-сервер) | 10001 | `https://dev.aggtaa.com/` | forward-auth (Authelia) | | `server-be` (Fastify), админский API | 10002 | `https://dev.aggtaa.com/api/admin/*` | forward-auth (Authelia), прокси передаёт `Remote-Email` | | `server-be` (Fastify), клиентский API | 10002 | `https://dev.aggtaa.com/api/*` | нет | | `mobile-client` (PWA) | 10003 | `https://dev.aggtaa.com/m` | нет | - Прокси срезает префиксы `/api` и `/m` и передаёт их в заголовке `X-Forwarded-Prefix`. На `server-be` админский API обслуживается по `/admin/*`, клиентский — от корня (`/auth`, `/cards`, …); на `mobile-client` запросы приходят от корня. - Админский UI обращается к `/api/admin/*` напрямую (тот же origin, та же cookie сессии Authelia). Запросы отправляются с `Accept: application/json` и `X-Requested-With: XMLHttpRequest`, чтобы при истёкшей сессии Authelia отвечала `401`, а не редиректом; на такой `401` (ответ не от Cardy) UI перезагружает страницу, и Authelia показывает форму входа. - `server-be` по умолчанию слушает только `127.0.0.1`: админский API доверяет заголовку `Remote-Email`. Дополнительная защита — `ADMIN_PROXY_SECRET`: прокси добавляет заголовок `X-Cardy-Proxy-Secret` на маршруте `/api/admin`, без него `/admin/*` отвечает `401`. - PWA на `/m` и API на `/api` — один origin, CORS для PWA не нужен; для Capacitor-приложения CORS включён (кроме `/admin/*`). - Для локальной разработки без прокси Vite (`server-fe`) эмулирует прокси: пересылает `/api/*` на `server-be` со срезанным `/api` и `X-Forwarded-Prefix: /api`. ## 3. Сервер ### 3.1. Конфигурация (переменные окружения) Задаются в окружении или в файле `.env` в каталоге приложения (образцы — `.env.example`). `server-be`: | Переменная | Обязательна | Описание | |---|---|---| | `KEY_ENCRYPTION_SECRET` | да | 32 байта в base64 — ключ AES-256-GCM для шифрования ключей доступа | | `SUPERADMIN_EMAIL` | да | email первого суперадмина | | `PUBLIC_API_URL` | нет | публичный адрес API (`https://dev.aggtaa.com/api`) для QR-кода ключа; если не задан — вычисляется из `X-Forwarded-Proto`/`X-Forwarded-Host`/`X-Forwarded-Prefix` | | `ADMIN_PROXY_SECRET` | нет | если задан, `/admin/*` требует заголовок `X-Cardy-Proxy-Secret` с этим значением | | `SUPERADMIN_GROUP` | нет, `default` | имя группы, в которую создаётся суперадмин из `SUPERADMIN_EMAIL` | | `DATA_DIR` | нет, `./data` | каталог данных | | `HOST` / `PORT` | нет, `127.0.0.1` / `10002` | адрес прослушивания | `server-fe`: | Переменная | Описание | |---|---| | `ALLOWED_HOSTS` | публичные имена хоста для Vite (по умолчанию `dev.aggtaa.com`) | | `BACKEND_URL` | только локально: адрес `server-be` (по умолчанию `http://127.0.0.1:10002`) | | `ADMIN_PROXY_SECRET` | только локально: тот же секрет, что у `server-be` | | `DEV_REMOTE_EMAIL` | только локально, без Authelia: подставляет `Remote-Email` | При старте: если пользователя `SUPERADMIN_EMAIL` нет — он создаётся в группе `SUPERADMIN_GROUP` (группа создаётся, если её нет); если есть — ему гарантированно выставляется роль суперадмина. ### 3.2. Хранилище ``` DATA_DIR/ users.yaml # группы, пользователи, зашифрованные ключи groups//cards.yaml # карты группы groups//images//. ``` - Папки групп называются по неизменяемому ID группы, а не по имени — переименование группы не трогает файлы. - Запись файлов атомарная (временный файл + rename), записи в один файл сериализуются. - Если yaml-файл изменён вручную, сервер перечитывает его при следующем обращении (по времени изменения). `users.yaml`: ```yaml groups: - id: 3f1c… # UUID name: Family users: - email: alice@example.com # уникален глобально, хранится в нижнем регистре groupId: 3f1c… role: superadmin # user | admin | superadmin keys: - id: 9a0b… # UUID label: Alice's phone # опционально createdAt: 2026-09-29T10:00:00.000Z encrypted: ``` `cards.yaml`: ```yaml cards: - id: 7c9e… # UUID, генерируется сервером name: Magnit color: "#E53935" # #RRGGBB code: "4600000000000" codeType: EAN13 logo: { id: 1d2e…, file: 1d2e….webp, mime: image/webp } # или null images: # порядок = порядок добавления - { id: 5f6a…, file: 5f6a….webp, mime: image/webp } imagesVersion: 3 deleted: false createdAt: … updatedAt: … ``` Типы кодов: `EAN13`, `EAN8`, `UPCA`, `UPCE`, `CODE128`, `CODE39`, `ITF`, `CODABAR`, `QR`, `DATAMATRIX`, `PDF417`, `AZTEC`. `imagesVersion` начинается с 0 и увеличивается на 1 при любом добавлении, замене или удалении лого или картинки — и из админского UI, и через клиентский API. Картинки сохраняются как есть, без валидации типа и размера. Лимит размера запроса задаёт реверс-прокси; собственный лимит сервера на размер тела запроса отключён. При отдаче картинок сервер выставляет `X-Content-Type-Options: nosniff` и `Content-Security-Policy: sandbox`, а для типов не `image/*` — `Content-Type: application/octet-stream`. ### 3.3. Пользователи, группы, роли - Пользователь состоит ровно в одной группе. - `admin` — администратор только своей группы. `superadmin` — администратор всех групп. - Карты общие для всех пользователей группы. - Группу, в которой есть хотя бы один суперадмин, удалить нельзя. При удалении группы удаляются все её пользователи, их ключи и все карты с картинками. - Нельзя удалить самого себя и нельзя понизить свою роль. Нельзя удалить или понизить последнего суперадмина. - `admin` может создавать, удалять и менять роль (`user`/`admin`) пользователей своей группы. Назначать `superadmin`, изменять суперадминов и переносить пользователей между группами может только `superadmin`. - Новые ключи генерирует только админ (для пользователей своей группы) или суперадмин. ### 3.4. Ключи доступа - 32 случайных символа из `[A-Za-z0-9]` (криптостойкий генератор). - Хранятся зашифрованными AES-256-GCM (случайный 12-байтный IV на каждый ключ). - Сессий нет: клиент передаёт ключ в каждом запросе (`Authorization: Bearer <ключ>`). Удалённый ключ перестаёт работать сразу. - QR-код ключа содержит deep link: `cardy://login?server=<публичный адрес API, URL-encoded>&key=<ключ>`. Клиент обращается к `/auth`, `/cards` и т.д. ### 3.5. Аутентификация административного UI - Пользователь определяется по заголовку `Remote-Email` (без учёта регистра). Нет заголовка или пользователь не найден в `users.yaml` — `401`. Недостаточно прав — `403`. - Текущая группа: для `user` и `admin` всегда своя группа. Для `superadmin` — значение cookie `cardy_group` (если его нет или группа не существует — своя группа); UI суперадмина позволяет выбрать другую группу, что записывает cookie. - Если задан `ADMIN_PROXY_SECRET`, без правильного заголовка `X-Cardy-Proxy-Secret` — `401`. - Изменяющие запросы (`POST`/`PUT`/`PATCH`/`DELETE`) к админскому API требуют заголовка `X-Cardy-Admin: 1` (защита от CSRF). ### 3.6. Административный UI (`server-fe`, `https://dev.aggtaa.com/`) Страницы: `/cards`, `/cards/:id`, `/cards/new`, `/users`, `/groups`, `/keys`. - Шапка: email, роль, текущая группа; у суперадмина — выбор текущей группы. - **My keys** (все пользователи): список своих ключей; для выбранного ключа — QR-код с deep link, сам ключ текстом, кнопка копирования. - **Users** (admin, superadmin): CRUD пользователей текущей группы (email, роль; у суперадмина — ещё группа), по каждому пользователю — список ключей, генерация ключа (с опциональной меткой), удаление ключа, показ QR. - **Groups** (superadmin): CRUD групп (имя, уникально). - **Cards** (admin, superadmin): список всех карт текущей группы, включая удалённые (фильтр по названию, переключатель «показывать удалённые»). Создание и редактирование: название, цвет, код, тип кода, предпросмотр кода, лого (загрузка/удаление), картинки (загрузка/удаление). Удаление помечает карту удалённой; удалённую карту можно восстановить или удалить окончательно (вместе с картинками). ### 3.7. API административного UI (публично `/api/admin`, на сервере `/admin`) Пути ниже — относительно этого префикса. | Метод и путь | Доступ | Описание | |---|---|---| | `GET /me` | все | текущий пользователь, текущая группа | | `GET /my-keys` | все | свои ключи (расшифрованные) и deep link | | `GET /groups` | superadmin | список групп | | `POST /groups`, `PATCH /groups/:id`, `DELETE /groups/:id` | superadmin | CRUD групп | | `PUT /current-group` | superadmin | выбор текущей группы (cookie) | | `GET /users` | admin | пользователи текущей группы с ключами | | `POST /users`, `PATCH /users/:email`, `DELETE /users/:email` | admin | CRUD пользователей | | `POST /users/:email/keys`, `DELETE /users/:email/keys/:keyId` | admin | генерация и удаление ключей | | `GET /cards` | admin | все карты текущей группы, включая удалённые | | `POST /cards`, `PATCH /cards/:id` | admin | создание и изменение (JSON; `deleted: false` восстанавливает) | | `DELETE /cards/:id` | admin | пометить удалённой; для уже удалённой — удалить окончательно | | `PUT /cards/:id/logo`, `DELETE /cards/:id/logo` | admin | лого (multipart, поле `file`) | | `POST /cards/:id/images`, `DELETE /cards/:id/images/:imageId` | admin | картинки (multipart, одно или несколько полей `file`) | | `GET /cards/:id/logo`, `GET /cards/:id/images/:imageId` | admin | получить файл | ### 3.8. Клиентский API (публично `/api`, на сервере — от корня) Все запросы с `Authorization: Bearer <ключ>`; неверный или отсутствующий ключ — `401`. Группа определяется по владельцу ключа. Удалённые карты для клиентского API не существуют (`404`). CORS разрешён для любого origin (нужно для Capacitor, origin `https://localhost`); cookie не используются. | Метод и путь | Описание | |---|---| | `GET /api/auth` | проверка ключа; возвращает email и группу | | `GET /api/cards` | все неудалённые карты группы, без пагинации | | `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально файл `logo`); возвращает карту с ID | | `PUT /api/cards/:id` | изменить карту (multipart: те же поля, опционально файл `logo`, опционально `removeLogo=true`) | | `DELETE /api/cards/:id` | пометить карту удалённой | | `GET /api/cards/:id/logo` | лого | | `GET /api/cards/:id/images/:imageId` | картинка | | `POST /api/cards/:id/images` | добавить картинки (multipart, одно или несколько полей `file`) | | `DELETE /api/cards/:id/images/:imageId` | удалить картинку | Все изменяющие запросы возвращают карту целиком (включая новый `imagesVersion`). При конкурентных правках побеждает последняя запись. ## 4. Клиентское мобильное приложение React + Vite + TS; PWA и Android-приложение через Capacitor. PWA раздаётся на порту 10003 с базовым путём `/m` (`https://dev.aggtaa.com/m`). - Хранит один ключ, адрес сервера и хэш PIN-кода (Capacitor Preferences / localStorage). Картинки — в Filesystem (Capacitor) или IndexedDB (PWA). - **Deep link `cardy://login?server=…&key=…`** зарегистрирован в Android-приложении (intent filter для схемы `cardy`, хост `login`). Открытие ссылки (например, QR из системной камеры) открывает экран нового входа с заполненными полями. - **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний. - **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход. - **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, фильтр по подстроке названия (от 1 символа), кнопки «обновить» и «добавить». - **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. - **Экран карты**: название, код (отрисовка `bwip-js`), локальные картинки. На Android яркость поднимается до максимума. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением). - **Экран добавления и редактирования**: название, цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (Android — ML Kit, PWA — `zxing-wasm`). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется. - После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера. - Приложение работает без интернета, кроме аутентификации, скачивания, добавления, редактирования и удаления карт и картинок. Эти функции без сети показывают сообщение об ошибке и прерываются (очереди отложенных операций нет). ## 5. Тесты Unit- и интеграционные тесты сервера на Vitest: шифрование, генерация ключей, yaml-хранилище, права доступа, маршруты API (через `fastify.inject`). E2E-тестов нет.