Files
cardy/development specification-v2.md
T

33 KiB
Raw Blame History

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/<groupId>/cards.yaml    # карты группы
  groups/<groupId>/images/<cardId>/<imageId>.<ext>
  • Папки групп называются по неизменяемому ID группы, а не по имени — переименование группы не трогает файлы.
  • Запись файлов атомарная (временный файл + rename), записи в один файл сериализуются.
  • Если yaml-файл изменён вручную, сервер перечитывает его при следующем обращении (по времени изменения).

users.yaml:

groups:
  - id: 3f1c…            # UUID
    name: Family
users:
  - email: alice@example.com   # уникален глобально, хранится в нижнем регистре
    superadmin: true          # глобальный флаг
    memberships:              # одна или несколько групп, в каждой своя роль
      - { groupId: 3f1c…, role: admin }   # user | admin
    keys:
      - id: 9a0b…             # UUID
        label: Alice's phone  # опционально
        createdAt: 2026-09-29T10:00:00.000Z
        encrypted: <base64(iv | authTag | ciphertext)>

cards.yaml:

cards:
  - id: 7c9e…                 # UUID, генерируется сервером
    name: Magnit
    color: "#E53935"          # #RRGGBB
    code: "4600000000000"
    codeType: EAN13
    comment: "Card of Anna\nScan twice"   # многострочный, до 2000 символов, '' если пусто
    tags: [продукты, семья]             # [] если нет
    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. Пользователи, группы, роли

  • Пользователь состоит в одной или нескольких группах (но не в нуле), в каждой — со своей ролью user или admin. superadmin — глобальный флаг: суперадмин — администратор всех групп. Записи старого формата (groupId + role) читаются как членство в одной группе и сохраняются в новом формате при первой записи.
  • admin — администратор только своей группы. superadmin — администратор всех групп.
  • Карты общие для всех пользователей группы.
  • Группу нельзя удалить, пока она единственная для какого-либо пользователя (сервер перечисляет таких пользователей). При удалении группы удаляются её карты с картинками и членства в ней; пользователи остаются в своих других группах.
  • Нельзя удалить себя из группы, менять свою роль и свой флаг superadmin. Нельзя снять флаг с последнего суперадмина или удалить его.
  • Админ группы добавляет в неё пользователей (новый email создаёт пользователя, существующий — добавляет ему членство), меняет их роль в этой группе и убирает их из неё; если группа была у пользователя единственной, пользователь удаляется вместе с ключами. Назначать и снимать 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=<ключ>. Клиент обращается к <server>/auth, <server>/cards и т.д.

3.5. Аутентификация административного UI

  • Пользователь определяется по заголовку Remote-Email (без учёта регистра). Нет заголовка или пользователь не найден в users.yaml — 401. Недостаточно прав — 403.
  • Текущая группа — значение cookie cardy_group, если пользователь в ней состоит (суперадмин — любая существующая группа); иначе первая группа пользователя. Переключатель в шапке есть у всех, у кого больше одной группы (у суперадмина — все группы). Роль и права в UI определяются ролью в текущей группе.
  • Если задан 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 участники текущей группы: роль в ней, флаг superadmin, все их группы, ключи
POST /users {email, role} admin добавить в текущую группу (новый email создаёт пользователя)
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/groups/:groupId/cards… (чужая группа — 404); прежние маршруты без группы (/api/cards…) работают с первой группой владельца — для приложений, выпущенных до поддержки нескольких групп. Удалённые карты для клиентского API не существуют (404). CORS разрешён для любого origin (нужно для Capacitor, origin https://localhost); cookie не используются.

Метод и путь Описание
GET /api/auth проверка ключа; возвращает email, groups (все группы владельца) и group (первая)
GET /api/cards все неудалённые карты группы, без пагинации
POST /api/cards создать карту (multipart: поля name, color, code, codeType, опционально comment, tags (строка через запятую) и файл logo); возвращает карту с ID
PUT /api/cards/:id изменить карту (multipart: любые из тех же полей — не переданные не меняются, пустой comment очищает комментарий, пустые tags — теги; опционально файл 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). При конкурентных правках побеждает последняя запись.

Теги карты. Вводятся строкой через запятую; у каждого тега обрезаются ведущие и концевые пробелы, пустые теги отбрасываются, точные повторы тоже (один,,, два → [один, два]). Не больше 30 тегов, каждый до 50 символов. API принимает строку или массив строк (к каждому элементу применяется то же правило).

4. Клиентское мобильное приложение

React + Vite + TS; PWA и Android-приложение через Capacitor. PWA раздаётся на порту 10003 (vite preview собранной версии; vite — для разработки) с базовым путём /m (https://dev.aggtaa.com/m); прокси срезает /m, сервер восстанавливает его сам. Сборка для Capacitor (pnpm build:android) — с относительным базовым путём, в dist-capacitor. Service worker кэширует приложение целиком (включая bwip-js и wasm-модуль сканера) для работы офлайн.

  • Хранит один ключ, адрес API и хэш PIN-кода (SHA-256 с солью), список карт, даты использования и версии скачанных картинок — в Capacitor Preferences (в PWA это localStorage). Картинки — в IndexedDB (и в PWA, и в WebView Capacitor).
  • Deep link cardy://login?server=…&key=… зарегистрирован в Android-приложении (intent filter для схемы cardy, хост login). Открытие ссылки (например, QR из системной камеры) открывает экран нового входа с заполненными полями.
  • Экран нового входа: поле адреса API (в PWA по умолчанию — <текущий origin>/api), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (GET <адрес API>/auth). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний; вместе с ним удаляются все локальные данные (карты, картинки, PIN), после входа задаётся новый PIN и карты скачиваются автоматически.
  • Навигация хранится в истории браузера: каждый переход между экранами — запись в истории (с хэшем вида #/card/<id>), поэтому кнопка «назад» в тулбаре, кнопка «назад» браузера и системная кнопка Android работают одинаково; «вперёд» тоже работает. После перезагрузки PWA (и ввода PIN) открывается тот же экран. Ключ из ссылки входа в историю и адресную строку не попадает.
  • Экран PIN-кода (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход («Forgot PIN? Sign in again» на экране PIN; отмена входа возвращает к экрану PIN). «Открытием» считается и возврат в приложение после 30 секунд в фоне.
  • Группы: приложение хранит список групп пользователя и текущую группу; карты, даты использования и версии картинок хранятся отдельно для каждой группы. Если групп больше одной, заголовок экрана карт — название текущей группы, нажатие открывает выбор группы; группа, карты которой ещё не скачивались на устройство, при выборе сразу обновляется. «Обновить» заодно обновляет список групп (данные групп, в которых пользователь больше не состоит, удаляются). Сессии старых версий приложения (без групп) при первом обновлении получают список групп, а их локальные карты переходят в первую группу. (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, поиск по подстроке (от 1 символа, без учёта регистра) в названии, тегах и комментарии, кнопки «обновить» и «добавить».
  • «Обновить»: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился imagesVersion, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы.
  • Экран карты: название, код (отрисовка bwip-js), теги, комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин ScreenBrightness в android/) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением).
  • Экран добавления и редактирования: название, комментарий (многострочное поле), теги (через запятую), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (barcode-detector на zxing-wasm и в PWA, и в Android WebView; wasm-модуль входит в сборку). После съёмки или выбора каждое изображение открывается на экране кадрирования: рамку можно двигать и менять за углы (для лого рамка квадратная, для фото свободная и по умолчанию охватывает всё изображение), изображение можно повернуть на 90°; «Cancel» пропускает изображение, несколько фото из галереи кадрируются по очереди; системная кнопка «назад» Android закрывает кадрирование (и сканер камеры), не уходя с экрана редактирования. Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с capture) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется.
  • После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера; загруженные картинки сохраняются локально без повторного скачивания. Если сохранение прервалось на середине (например, карта создана, а фото не загрузились), выполненные шаги сохраняются и «Save» повторяет только оставшиеся.
  • Обновления Android-приложения без переустановки APK (@capgo/capacitor-updater в ручном режиме, без облака Capgo, телеметрия отключена). APK содержит веб-сборку и полностью работает офлайн. pnpm publish:android собирает новую версию (номер — время сборки в UTC, ГГГГ.МДД.Ччммсс), кладёт zip и version.json (версия, SHA-256, минимальный versionCode APK) в mobile-client/updates/android; сервер mobile-client раздаёт их по /m/android/. При запуске и каждом возврате в приложение (не чаще раза в минуту, только при наличии связи) приложение проверяет version.json и скачивает более новую совместимую версию в фоне. На экране списка карт (разблокировано, без идущей загрузки картинок) она применяется сразу — перезагрузкой без повторного PIN; на экране карты, в редакторе, на экране входа или PIN — откладывается (next(): применяется при уходе в фон или при возврате к списку). Версия, не подтвердившая запуск за 10 секунд, откатывается к предыдущей. Установка нового APK сбрасывает скачанные версии.
  • Обновление самого APK (при изменениях нативной части: плагины, манифест, Java). pnpm publish:android-apk собирает релизный APK, подписанный ключом из android/keystore.properties (не в git; тем же ключом подписываются и отладочные сборки), с versionCode из времени сборки (ггДДДЧЧмм, UTC) и versionName = версия веб-сборки, и кладёт его вместе с apk.json (versionCode, versionName, SHA-256, размер) в mobile-client/updates/android. При запуске и возврате в приложение (не чаще раза в минуту) приложение сверяет apk.json со своим versionCode и при наличии более нового APK показывает на экране списка карт баннер «A new version of the app is available» с кнопкой Install: APK скачивается с прогрессом, проверяется SHA-256 и передаётся системному установщику Android (PackageInstaller). В первый раз нужно разрешить Cardy «Установку неизвестных приложений» (приложение открывает этот экран настроек), затем подтвердить системный диалог; на Android 12+ последующие обновления могут устанавливаться без подтверждения, если это разрешит система. Веб-обновления, опубликованные после выпуска APK, требуют этот APK или новее (minNativeBuild), старые установки получают предложение обновить APK.
  • Приложение работает без интернета, кроме аутентификации, скачивания, добавления, редактирования и удаления карт и картинок. Эти функции без сети показывают сообщение об ошибке и прерываются (очереди отложенных операций нет).

5. Тесты

Vitest. Сервер: шифрование, генерация ключей, yaml-хранилище, права доступа, маршруты API (через fastify.inject), интеграция с прокси. Клиент: пул загрузок, выбор карт для докачки, сортировка, PIN, распознаваемые форматы. E2E-тестов нет.