From 657009bd6cfbf26b58575fad7580ce3f1437d901 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Tue, 29 Sep 2026 13:50:01 +0300 Subject: [PATCH] server be & fe --- .claude/launch.json | 28 + .gitignore | 5 + README.md | 44 + development specification-v2.md | 210 +++ development specification.md | 65 + package.json | 12 + pnpm-lock.yaml | 1926 ++++++++++++++++++++++++++++ pnpm-workspace.yaml | 6 + server-be/.env.example | 25 + server-be/build.mjs | 25 + server-be/package.json | 29 + server-be/src/app.ts | 50 + server-be/src/cards.ts | 240 ++++ server-be/src/config.ts | 61 + server-be/src/crypto.ts | 29 + server-be/src/errors.ts | 14 + server-be/src/forwarded.ts | 18 + server-be/src/http.ts | 56 + server-be/src/index.ts | 23 + server-be/src/routes/adminApi.ts | 241 ++++ server-be/src/routes/clientApi.ts | 95 ++ server-be/src/users.ts | 279 ++++ server-be/src/yamlFile.ts | 81 ++ server-be/test/adminApi.test.ts | 232 ++++ server-be/test/clientApi.test.ts | 133 ++ server-be/test/config.test.ts | 77 ++ server-be/test/helpers.ts | 51 + server-be/test/unit.test.ts | 81 ++ server-be/tsconfig.json | 8 + server-fe/.env.example | 12 + server-fe/index.html | 13 + server-fe/package.json | 28 + server-fe/src/App.tsx | 102 ++ server-fe/src/api.ts | 97 ++ server-fe/src/components.tsx | 214 ++++ server-fe/src/main.tsx | 10 + server-fe/src/pages/CardEditor.tsx | 211 +++ server-fe/src/pages/Cards.tsx | 55 + server-fe/src/pages/Groups.tsx | 98 ++ server-fe/src/pages/MyKeys.tsx | 32 + server-fe/src/pages/Users.tsx | 195 +++ server-fe/src/router.ts | 30 + server-fe/src/styles.css | 585 +++++++++ server-fe/tsconfig.json | 9 + server-fe/vite.config.ts | 43 + shared/package.json | 20 + shared/src/cardDisplay.test.ts | 18 + shared/src/cardDisplay.ts | 29 + shared/src/codeTypes.ts | 49 + shared/src/deepLink.test.ts | 17 + shared/src/deepLink.ts | 20 + shared/src/index.ts | 5 + shared/src/models.ts | 61 + shared/src/schemas.ts | 43 + shared/tsconfig.json | 7 + tsconfig.base.json | 15 + 56 files changed, 6162 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 development specification-v2.md create mode 100644 development specification.md create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 server-be/.env.example create mode 100644 server-be/build.mjs create mode 100644 server-be/package.json create mode 100644 server-be/src/app.ts create mode 100644 server-be/src/cards.ts create mode 100644 server-be/src/config.ts create mode 100644 server-be/src/crypto.ts create mode 100644 server-be/src/errors.ts create mode 100644 server-be/src/forwarded.ts create mode 100644 server-be/src/http.ts create mode 100644 server-be/src/index.ts create mode 100644 server-be/src/routes/adminApi.ts create mode 100644 server-be/src/routes/clientApi.ts create mode 100644 server-be/src/users.ts create mode 100644 server-be/src/yamlFile.ts create mode 100644 server-be/test/adminApi.test.ts create mode 100644 server-be/test/clientApi.test.ts create mode 100644 server-be/test/config.test.ts create mode 100644 server-be/test/helpers.ts create mode 100644 server-be/test/unit.test.ts create mode 100644 server-be/tsconfig.json create mode 100644 server-fe/.env.example create mode 100644 server-fe/index.html create mode 100644 server-fe/package.json create mode 100644 server-fe/src/App.tsx create mode 100644 server-fe/src/api.ts create mode 100644 server-fe/src/components.tsx create mode 100644 server-fe/src/main.tsx create mode 100644 server-fe/src/pages/CardEditor.tsx create mode 100644 server-fe/src/pages/Cards.tsx create mode 100644 server-fe/src/pages/Groups.tsx create mode 100644 server-fe/src/pages/MyKeys.tsx create mode 100644 server-fe/src/pages/Users.tsx create mode 100644 server-fe/src/router.ts create mode 100644 server-fe/src/styles.css create mode 100644 server-fe/tsconfig.json create mode 100644 server-fe/vite.config.ts create mode 100644 shared/package.json create mode 100644 shared/src/cardDisplay.test.ts create mode 100644 shared/src/cardDisplay.ts create mode 100644 shared/src/codeTypes.ts create mode 100644 shared/src/deepLink.test.ts create mode 100644 shared/src/deepLink.ts create mode 100644 shared/src/index.ts create mode 100644 shared/src/models.ts create mode 100644 shared/src/schemas.ts create mode 100644 shared/tsconfig.json create mode 100644 tsconfig.base.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..8d39f8e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,28 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "server-be (local test)", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter", "@cardy/server-be", "exec", "tsx", "src/index.ts"], + "port": 10002, + "env": { + "KEY_ENCRYPTION_SECRET": "cHrUrVMk3KgaAw066+CPj7iYXuBfBXbH5lPLFwoBSLA=", + "SUPERADMIN_EMAIL": "admin@local.test", + "ADMIN_PROXY_SECRET": "local-proxy-secret", + "DATA_DIR": "C:/Users/User/AppData/Local/Temp/claude/D--Documents-ProjectsGit-Cardy/bf03f49e-0768-4ffa-b6d2-c9a06a1562aa/scratchpad/data" + } + }, + { + "name": "server-fe (local test)", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter", "@cardy/server-fe", "exec", "vite"], + "port": 10001, + "env": { + "ADMIN_PROXY_SECRET": "local-proxy-secret", + "DEV_REMOTE_EMAIL": "admin@local.test", + "ALLOWED_HOSTS": "localhost" + } + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddc0ba2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +data/ +.env +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..03c871f --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Cardy + +Discount card storage. Specification: [development specification-v2.md](development%20specification-v2.md). + +| Package | Port | Public URL | Proxy auth | +|---|---|---|---| +| `server-fe` — admin UI (Vite) | 10001 | `https://dev.aggtaa.com/` | Authelia | +| `server-be` — admin API | 10002 | `https://dev.aggtaa.com/api/admin/*` → `/admin/*` | Authelia, passes `Remote-Email` | +| `server-be` — client API | 10002 | `https://dev.aggtaa.com/api/*` → `/*` | none | +| `mobile-client` — PWA | 10003 | `https://dev.aggtaa.com/m/*` → `/*` | none | + +The proxy strips `/api` and `/m` and passes them in `X-Forwarded-Prefix`. + +## Setup + +```bash +pnpm install +cp server-be/.env.example server-be/.env # fill in KEY_ENCRYPTION_SECRET, SUPERADMIN_EMAIL +cp server-fe/.env.example server-fe/.env +``` + +## Run + +```bash +pnpm --filter @cardy/server-be dev # API on 127.0.0.1:10002 (tsx watch) +pnpm --filter @cardy/server-fe dev # admin UI on :10001 +``` + +Production-like: `pnpm --filter @cardy/server-be build && pnpm --filter @cardy/server-be start`, +`pnpm --filter @cardy/server-fe start` (build + `vite preview` on :10001). + +`server-be` listens on `127.0.0.1` by default because `/admin/*` trusts `Remote-Email`. +If the proxy runs on another machine, set `HOST` and `ADMIN_PROXY_SECRET`, and make the proxy add +`X-Cardy-Proxy-Secret` on the `/api/admin` route. + +Without the proxy (local development), `server-fe` emulates it: forwards `/api/*` to `server-be` +with `/api` stripped; `DEV_REMOTE_EMAIL` in `server-fe/.env` fakes the Authelia user. + +## Tests + +```bash +pnpm test +pnpm typecheck +``` diff --git a/development specification-v2.md b/development specification-v2.md new file mode 100644 index 0000000..3a180f5 --- /dev/null +++ b/development specification-v2.md @@ -0,0 +1,210 @@ +# 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-тестов нет. diff --git a/development specification.md b/development specification.md new file mode 100644 index 0000000..05125dd --- /dev/null +++ b/development specification.md @@ -0,0 +1,65 @@ +# pnpm монорепо приложение для хранения скидочных карт (штрих/qr-код + тип кода, EAN-13, EAN-8, Code128, QR, PDF417, Aztec и т.д. + название + цвет + опционально лого + опционально скриншоты). + +# серверное приложение (fastify+react+vite+ts): +* хранение пользователей в файле users.yaml (по email) и их групп (по названиям). для пользователя может быть храниться признак того, что это - администратор или суперадминистратор. +* пользователь является администратором только для своей группы. суперадминистратор - администратор всех групп. +* аутентификация пользователя для доступа к UI (не API) через заголовок Remote-Email +* для каждого пользователя может храниться несколько строковых ключей доступа. ключи хранятся в зашифрованном обратимым шифрованием AES-256-GCM виде, ключ расшифровки (32-байта в base64) в переменной окружении. +* административный (для суперадмина) CRUD UI для управления группами пользователей и выбора одной группы, как текущей. группу, к которой принадлежит хотя бы один суперадмин, невозможно удалить. +* административный UI CRUD пользователей текущей группы и приязанных к ним ключей доступа. ключи генерируются случайно (32 символа) +* хранилище списка карт в yaml файле в папке соответственной группы пользователей, картинки рядом с ним на диске в виде файлов. количество картинок на карту не ограничено. карты общие для всех пользователей одной группы. для каждой карты указывается числовой номер версии картинок. +* административный UI для CRUD всех хранящихся, включая удаленные, карт текущей группы пользователей, загрузки картинок к ним. изменение/добавление/удаление любой картинки увеличивает номер версии в карте. загруженные картинки хранятся как есть. +* UI для показа списка ключей текущего пользователя. Для одного выбранного ключа генерирутся QR код, содержащий адрес сервера и сам ключ. +* API для клиентского приложения: + * аутентификация пользователя по ключу (Authorization: Bearer) + * получение списка всех карт (без пагинации). удаленные карты получить невозможно + * получение картинок по выбранной карте + * редактирование, удаление и добавление карт с их лого. удалённые карты помечаются, как удаленные и не удаляются + * удаление и добавление картинок карт +* весь UI на английском + +# клиентское мобильное приложение (react/vite/ts pwa + capacitor андроид): +* хранение ключа и пинкода для входа +* экран нового входа + * поле для ввода адреса серверного API + * показывает камеру, даёт отсканировать ключ из QR кода + * позволяет ввести ключ текстом в открытом виде. + обязательна аутентификация на сервере для подтверждения правильности ключа. экран можно вызвать даже если ключ уже хранится в приложении, в этом случае старый ключ удаляется. хранится всегда только один последний ключ +* экран цифрового кода (= "пинкода"). показывается для ввода пинкода (4 цифры) при смене ключа (пинкод сохраняется) и при каждом открытии приложения (пинкод сверяется с хранящимся). защиты от перебора нет. +* экран карт. список всех хранящихся локально карт (цветными названия с логотипами или, если их нет, с аббревиатурами на цветном фоне) с возможностью выбрать одну из них во весь экран. сортировка по давности использования +* экран (одной) карты: просмотр её названия, штрих/qr-кода и её хранящиеся локально картинки, если есть. яркость экрана на андроиде поднимается на максимум. открывание экрана считается использованием карты и сохраняет дату и время +* на экране карт кнопка "фильтр по названию" (по подстроке длиной от 1 символа) +* на экране карт кнопка "обновить". перекачивает заново список всех карт с сервера и обновляет сохраненный список (только если скачалось успешно). для изменившихся карт асинхронно по очереди (макс 5 запросов одновременно) скачиваются их картинки. +* экран редактирования/добавления новой карты: поле для ввода названия, выбора цвета (по умолчанию случайный), загрузки опционального лого, загрузки опциональных фото, ввода кода и выбора типа код карты текстом или распознаванием QR/штрих-кода и его типа с карты или из загруженной картинки. лого и картинки перед отправкой, пережимаются в webp до размера 1000px по наибольшей стороне, EXIF удаляется +* на экране карт кнопка "добавить", вызывает экран редактирования/добавления новой карты +* на экране карты кнопка "удалить" с подтверждением. +* на экране карты кнопка "редактировать", вызывает экран редактирования/добавления новой карты +* весь UI на английском + +клиентское приложение должно работать без интернета за исключением функций аутентификации, скачивания, редактирования, удаления и добавления карт и картинок. функции, требующие наличия сети, показывают ошибки сообщениями на экране и отваливаются. + +вопросы деплоя в этом задании не рассматриваются + + +# Дополнительная информация + +Пользователи и группы +* Пользователь состоит ровно в одной группе +* Первый суперадмин создаётся из переменной окружения SUPERADMIN_EMAIL +* При удалении группы все пользователи, ключи и карты удаляются. +* Текущая группа пользователя читается из users.yaml и сохраняется в cookie. UI суперадмина позволяет записать в эту cookie другое значение. +* При ошибка аутентификации отвечать ошибкой 401. +* Новые ключи генерируются только админом или суперадмином + +Сессии и ключи +* Шифрование ключей: AES-256-GCM с 32-байтным ключом в base64 в env подойдёт? + +Данные +* Где генерируется ID карты? На сервере, при добавлении. Возвращается клиенту. +* Конкурентные правки одной карты с разных устройств: побеждает последняя запись? Да. +* Дата последнего использования карты хранится только локально на устройстве и сохраняется при «обновить» по ID карты? Да. + +Клиент и деплой +* Как менять PIN без смены ключа? Или только через повторный вход? Да. +* PWA раздаётся с того же домена что и API. +* Нужны ли Dockerfile и docker-compose с томом под данные и пример конфига прокси с bypass для /api? Нет. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..6927685 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "cardy", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm -r build", + "test": "pnpm -r test", + "typecheck": "pnpm -r typecheck", + "dev:server": "pnpm --filter @cardy/server-be dev", + "dev:admin": "pnpm --filter @cardy/server-fe dev" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..8f54735 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1926 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + server-be: + dependencies: + '@cardy/shared': + specifier: workspace:* + version: link:../shared + '@fastify/cookie': + specifier: ^11.1.2 + version: 11.1.2 + '@fastify/cors': + specifier: ^11.3.0 + version: 11.3.0 + '@fastify/multipart': + specifier: ^10.1.2 + version: 10.1.2 + fastify: + specifier: ^5.12.5 + version: 5.12.5 + yaml: + specifier: ^2.9.1 + version: 2.9.1 + zod: + specifier: ^4.6.5 + version: 4.6.5 + devDependencies: + '@types/node': + specifier: ^22.20.4 + version: 22.20.4 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 + tsx: + specifier: ^4.23.15 + version: 4.23.15 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: ^5.0.2 + version: 5.0.2(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(tsx@4.23.15)(yaml@2.9.1)) + + server-fe: + dependencies: + '@cardy/shared': + specifier: workspace:* + version: link:../shared + bwip-js: + specifier: ^4.11.4 + version: 4.11.4 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 + react: + specifier: ^19.3.0 + version: 19.3.0 + react-dom: + specifier: ^19.3.0 + version: 19.3.0(react@19.3.0) + devDependencies: + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: ^6.1.1 + version: 6.1.1(vite@8.3.1(@types/node@22.20.4)) + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vite: + specifier: ^8.3.1 + version: 8.3.1(@types/node@22.20.4) + + shared: + dependencies: + zod: + specifier: ^4.6.5 + version: 4.6.5 + devDependencies: + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: ^5.0.2 + version: 5.0.2(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(tsx@4.23.15)(yaml@2.9.1)) + +packages: + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fastify/ajv-compiler@4.0.6': + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + + '@fastify/busboy@3.2.2': + resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==} + + '@fastify/cookie@11.1.2': + resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} + + '@fastify/cors@11.3.0': + resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==} + + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + + '@fastify/forwarded@3.0.2': + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/multipart@10.1.2': + resolution: {integrity: sha512-WHsqW5ffDXCbXXtdBQDryxu58fQvFPVGh4E4MlCO6hwCLNm0lYTCS6GKXUw2P64REDoECUNpMVvtMMstdIyBjQ==} + + '@fastify/proxy-addr@5.1.1': + resolution: {integrity: sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@rolldown/binding-android-arm-eabi@1.2.11': + resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.11': + resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.11': + resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.11': + resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.11': + resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.11': + resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.11': + resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.11': + resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.11': + resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.11': + resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.4': + resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} + + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/mocker@5.0.2': + resolution: {integrity: sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.2': + resolution: {integrity: sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + + bwip-js@4.11.4: + resolution: {integrity: sha512-FOXnOQkinxog8FwxdnPB+8k2wRqwPnqXwxX37KcXOtMNW+9CinOkGv9MtIoRC4HX/UyogT0/9lIp6FDm2DcquQ==} + hasBin: true + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fast-uri@4.2.1: + resolution: {integrity: sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==} + + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + + fastify@5.12.5: + resolution: {integrity: sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + find-my-way@9.9.0: + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} + engines: {node: '>=20'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.2.11: + resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite@8.3.1: + resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.2: + resolution: {integrity: sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.2 + '@vitest/browser-preview': 5.0.2 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.2 + '@vitest/coverage-v8': 5.0.2 + '@vitest/ui': 5.0.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + why-is-node-running@3.2.2: + resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==} + engines: {node: '>=20.11'} + hasBin: true + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@fastify/ajv-compiler@4.0.6': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.2.1 + + '@fastify/busboy@3.2.2': {} + + '@fastify/cookie@11.1.2': + dependencies: + cookie: 2.0.1 + fastify-plugin: 6.0.0 + + '@fastify/cors@11.3.0': + dependencies: + fastify-plugin: 6.0.0 + toad-cache: 3.7.4 + + '@fastify/deepmerge@3.2.1': {} + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/forwarded@3.0.2': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/multipart@10.1.2': + dependencies: + '@fastify/busboy': 3.2.2 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 6.0.0 + secure-json-parse: 4.1.0 + + '@fastify/proxy-addr@5.1.1': + dependencies: + '@fastify/forwarded': 3.0.2 + ipaddr.js: 2.5.0 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.151.0': {} + + '@pinojs/redact@0.4.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.11': + optional: true + + '@rolldown/binding-android-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-x64@1.2.11': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.11': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.11': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.11': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.11': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.11': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.4': + dependencies: + undici-types: 6.21.0 + + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.20.4 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitejs/plugin-react@6.1.1(vite@8.3.1(@types/node@22.20.4))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.1(@types/node@22.20.4) + + '@vitest/mocker@5.0.2(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(tsx@4.23.15)(yaml@2.9.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.2 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(tsx@4.23.15)(yaml@2.9.1) + + '@vitest/spy@5.0.2': {} + + abstract-logging@2.0.1: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + assertion-error@2.0.1: {} + + atomic-sleep@1.0.0: {} + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.3 + + bwip-js@4.11.4: {} + + camelcase@5.3.1: {} + + chai@6.2.2: {} + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + cookie@1.1.1: {} + + cookie@2.0.1: {} + + csstype@3.2.3: {} + + decamelize@1.2.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dijkstrajs@1.0.3: {} + + emoji-regex@8.0.0: {} + + es-module-lexer@2.3.2: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.2.1 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.8: {} + + fast-uri@4.2.1: {} + + fastify-plugin@6.0.0: {} + + fastify@5.12.5: + dependencies: + '@fastify/ajv-compiler': 4.0.6 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.1 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.9.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.1.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.4 + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + find-my-way@9.9.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + ipaddr.js@2.5.0: {} + + is-fullwidth-code-point@3.0.0: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + nanoid@3.3.19: {} + + obug@2.2.1: {} + + on-exit-leak-free@2.1.2: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + path-exists@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pngjs@5.0.0: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + process-warning@4.0.1: {} + + process-warning@5.1.0: {} + + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + quick-format-unescaped@4.0.4: {} + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react@19.3.0: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-main-filename@2.0.0: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rolldown@1.2.11: + dependencies: + '@oxc-project/types': 0.151.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.11 + '@rolldown/binding-android-arm64': 1.2.11 + '@rolldown/binding-darwin-arm64': 1.2.11 + '@rolldown/binding-darwin-x64': 1.2.11 + '@rolldown/binding-freebsd-x64': 1.2.11 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.11 + '@rolldown/binding-linux-arm64-gnu': 1.2.11 + '@rolldown/binding-linux-arm64-musl': 1.2.11 + '@rolldown/binding-linux-ppc64-gnu': 1.2.11 + '@rolldown/binding-linux-s390x-gnu': 1.2.11 + '@rolldown/binding-linux-x64-gnu': 1.2.11 + '@rolldown/binding-linux-x64-musl': 1.2.11 + '@rolldown/binding-openharmony-arm64': 1.2.11 + '@rolldown/binding-win32-arm64-msvc': 1.2.11 + '@rolldown/binding-win32-x64-msvc': 1.2.11 + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + scheduler@0.28.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.5: {} + + set-blocking@2.0.0: {} + + set-cookie-parser@2.7.2: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + std-env@4.2.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinybench@6.2.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + toad-cache@3.7.4: {} + + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@6.21.0: {} + + vite@8.3.1(@types/node@22.20.4): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.11 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.4 + fsevents: 2.3.3 + + vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(tsx@4.23.15)(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.11 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.4 + esbuild: 0.28.2 + fsevents: 2.3.3 + tsx: 4.23.15 + yaml: 2.9.1 + + vitest@5.0.2(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(tsx@4.23.15)(yaml@2.9.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.2(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(tsx@4.23.15)(yaml@2.9.1)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(tsx@4.23.15)(yaml@2.9.1) + why-is-node-running: 3.2.2 + optionalDependencies: + '@types/node': 22.20.4 + transitivePeerDependencies: + - msw + + which-module@2.0.1: {} + + why-is-node-running@3.2.2: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@4.0.3: {} + + yaml@2.9.1: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + zod@4.6.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..4b29e6c --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - shared + - server-be + - server-fe +allowBuilds: + esbuild: true diff --git a/server-be/.env.example b/server-be/.env.example new file mode 100644 index 0000000..5e5040b --- /dev/null +++ b/server-be/.env.example @@ -0,0 +1,25 @@ +# Copy to .env and fill in. + +# 32 random bytes in base64, e.g.: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +# Losing it makes all stored access keys unreadable. +KEY_ENCRYPTION_SECRET= + +# Created (or promoted) as superadmin on start. +SUPERADMIN_EMAIL= +# Group of that superadmin if it has to be created. +SUPERADMIN_GROUP=default + +# The reverse proxy strips /api and sends X-Forwarded-Prefix: /api. +# public /api/admin/* (Authelia) -> /admin/* +# public /api/* (no auth) -> /* +# Public API URL for key QR codes. Optional: derived from X-Forwarded-Proto/Host/Prefix when empty. +# PUBLIC_API_URL=https://dev.aggtaa.com/api + +# Optional extra protection: when set, /admin/* requires the header X-Cardy-Proxy-Secret with this value. +# Configure the reverse proxy to add it on the /api/admin route. Recommended if HOST is not loopback. +# ADMIN_PROXY_SECRET= + +DATA_DIR=./data +# Loopback by default: /admin/* trusts Remote-Email, so only the local reverse proxy may connect. +HOST=127.0.0.1 +PORT=10002 diff --git a/server-be/build.mjs b/server-be/build.mjs new file mode 100644 index 0000000..fe3a4ec --- /dev/null +++ b/server-be/build.mjs @@ -0,0 +1,25 @@ +import { build } from 'esbuild'; +import path from 'node:path'; + +// Bundle workspace packages (they ship TypeScript sources), keep npm dependencies external. +const externalNpmPackages = { + name: 'external-npm-packages', + setup(b) { + b.onResolve({ filter: /.*/ }, (args) => { + if (args.kind === 'entry-point' || args.path.startsWith('.') || path.isAbsolute(args.path)) return undefined; + if (args.path.startsWith('@cardy/')) return undefined; + return { path: args.path, external: true }; + }); + }, +}; + +await build({ + entryPoints: ['src/index.ts'], + outfile: 'dist/index.js', + bundle: true, + platform: 'node', + format: 'esm', + target: 'node22', + sourcemap: true, + plugins: [externalNpmPackages], +}); diff --git a/server-be/package.json b/server-be/package.json new file mode 100644 index 0000000..fffd2da --- /dev/null +++ b/server-be/package.json @@ -0,0 +1,29 @@ +{ + "name": "@cardy/server-be", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch --env-file-if-exists=.env src/index.ts", + "build": "node build.mjs", + "start": "node --env-file-if-exists=.env dist/index.js", + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "@cardy/shared": "workspace:*", + "@fastify/cookie": "^11.1.2", + "@fastify/cors": "^11.3.0", + "@fastify/multipart": "^10.1.2", + "fastify": "^5.12.5", + "yaml": "^2.9.1", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/node": "^22.20.4", + "esbuild": "^0.28.2", + "tsx": "^4.23.15", + "typescript": "^7.0.2", + "vitest": "^5.0.2" + } +} diff --git a/server-be/src/app.ts b/server-be/src/app.ts new file mode 100644 index 0000000..ab30b7e --- /dev/null +++ b/server-be/src/app.ts @@ -0,0 +1,50 @@ +import cookie from '@fastify/cookie'; +import multipart from '@fastify/multipart'; +import Fastify, { type FastifyServerOptions } from 'fastify'; +import { ZodError } from 'zod'; +import { CardsService } from './cards.js'; +import { ADMIN_API_PREFIX, type Config } from './config.js'; +import { HttpError } from './errors.js'; +import { formatZodError } from './http.js'; +import { adminApi } from './routes/adminApi.js'; +import { clientApi } from './routes/clientApi.js'; +import { UsersService } from './users.js'; + +// Upload size is limited by the reverse proxy, not by the server. +const NO_LIMIT = Number.MAX_SAFE_INTEGER; + +export async function buildApp(config: Config, options: FastifyServerOptions = {}) { + // trustProxy: protocol and host come from X-Forwarded-* set by the reverse proxy. + const app = Fastify({ bodyLimit: NO_LIMIT, trustProxy: true, ...options }); + + const users = new UsersService(config.dataDir, config.keySecret); + const cards = new CardsService(config.dataDir); + await users.ensureSuperadmin(config.superadminEmail, config.superadminGroup); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof HttpError) return reply.code(error.statusCode).send({ error: error.message }); + if (error instanceof ZodError) return reply.code(400).send({ error: formatZodError(error) }); + const statusCode = (error as { statusCode?: number }).statusCode; + if (statusCode && statusCode >= 400 && statusCode < 500) { + return reply.code(statusCode).send({ error: (error as Error).message }); + } + request.log.error(error); + return reply.code(500).send({ error: 'Internal server error' }); + }); + + app.setNotFoundHandler((_request, reply) => reply.code(404).send({ error: 'Not found' })); + + await app.register(cookie); + await app.register(multipart, { limits: { fileSize: NO_LIMIT, files: NO_LIMIT, fieldSize: 1024 * 1024 } }); + + await app.register(adminApi, { + prefix: ADMIN_API_PREFIX, + users, + cards, + proxySecret: config.adminProxySecret, + publicApiUrl: config.publicApiUrl, + }); + await app.register(clientApi, { users, cards }); + + return app; +} diff --git a/server-be/src/cards.ts b/server-be/src/cards.ts new file mode 100644 index 0000000..c7079f2 --- /dev/null +++ b/server-be/src/cards.ts @@ -0,0 +1,240 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, rm, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; +import { CODE_TYPES, type Card, type CardFields, type ImageRef } from '@cardy/shared'; +import { badRequest, notFound } from './errors.js'; +import { YamlFile } from './yamlFile.js'; + +const safeId = z.string().regex(/^[A-Za-z0-9-]+$/, 'Invalid id'); +const safeFileName = z.string().regex(/^[A-Za-z0-9-]+\.[A-Za-z0-9]{1,10}$/, 'Invalid file name'); + +const imageRefSchema = z.object({ id: safeId, file: safeFileName, mime: z.string() }); + +const cardSchema = z.object({ + id: safeId, + name: z.string(), + color: z.string(), + code: z.coerce.string(), + codeType: z.enum(CODE_TYPES), + logo: imageRefSchema.nullable().default(null), + images: z.array(imageRefSchema).default([]), + imagesVersion: z.number().int().default(0), + deleted: z.boolean().default(false), + createdAt: z.string(), + updatedAt: z.string(), +}); + +const cardsFileSchema = z.object({ cards: z.array(cardSchema).default([]) }); +type CardsFile = z.infer; + +export interface Upload { + data: Buffer; + mime: string; + filename?: string; +} + +export interface CardAccess { + /** Admin UI sees deleted cards; the client API does not. */ + includeDeleted: boolean; +} + +const MIME_EXTENSIONS: Record = { + 'image/webp': 'webp', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/avif': 'avif', + 'image/heic': 'heic', + 'image/heif': 'heif', + 'image/bmp': 'bmp', + 'image/svg+xml': 'svg', +}; + +function extensionFor(upload: Upload): string { + const byMime = MIME_EXTENSIONS[upload.mime.toLowerCase()]; + if (byMime) return byMime; + const byName = path.extname(upload.filename ?? '').slice(1).toLowerCase(); + return /^[a-z0-9]{1,10}$/.test(byName) ? byName : 'bin'; +} + +export class CardsService { + private readonly files = new Map>(); + + constructor(private readonly dataDir: string) {} + + private groupDir(groupId: string): string { + return path.join(this.dataDir, 'groups', safeId.parse(groupId)); + } + + private imageDir(groupId: string, cardId: string): string { + return path.join(this.groupDir(groupId), 'images', safeId.parse(cardId)); + } + + private file(groupId: string): YamlFile { + let file = this.files.get(groupId); + if (!file) { + file = new YamlFile( + path.join(this.groupDir(groupId), 'cards.yaml'), + (raw) => cardsFileSchema.parse(raw ?? {}), + () => ({ cards: [] }), + ); + this.files.set(groupId, file); + } + return file; + } + + async list(groupId: string, access: CardAccess): Promise { + const { cards } = await this.file(groupId).read(); + return access.includeDeleted ? cards : cards.filter((c) => !c.deleted); + } + + async get(groupId: string, cardId: string, access: CardAccess): Promise { + const card = (await this.file(groupId).read()).cards.find((c) => c.id === cardId); + if (!card || (card.deleted && !access.includeDeleted)) throw notFound('Card not found'); + return card; + } + + /** Path of the card's logo or image file on disk. */ + async imagePath(groupId: string, cardId: string, imageId: 'logo' | string, access: CardAccess) { + const card = await this.get(groupId, cardId, access); + const ref = imageId === 'logo' ? card.logo : card.images.find((i) => i.id === imageId); + if (!ref) throw notFound('Image not found'); + return { path: path.join(this.imageDir(groupId, card.id), ref.file), mime: ref.mime }; + } + + async create(groupId: string, fields: CardFields, logo?: Upload): Promise { + const id = randomUUID(); + const now = new Date().toISOString(); + const logoRef = logo ? await this.saveFile(groupId, id, logo) : null; + const card: Card = { + id, + ...fields, + logo: logoRef, + images: [], + imagesVersion: logoRef ? 1 : 0, + deleted: false, + createdAt: now, + updatedAt: now, + }; + try { + await this.file(groupId).update((d) => { + d.cards.push(card); + }); + } catch (err) { + await rm(this.imageDir(groupId, id), { recursive: true, force: true }); + throw err; + } + return card; + } + + /** + * Updates fields and optionally the logo (`logo: null` removes it). + * The client API cannot touch deleted cards nor change the `deleted` flag. + */ + async update( + groupId: string, + cardId: string, + patch: Partial & { deleted?: boolean }, + access: CardAccess, + logo?: Upload | null, + ): Promise { + if (patch.deleted !== undefined && !access.includeDeleted) throw badRequest('Cannot change deleted flag'); + await this.get(groupId, cardId, access); + const newLogo = logo ? await this.saveFile(groupId, cardId, logo) : undefined; + let oldLogo = null as ImageRef | null; + let card: Card; + try { + card = await this.mutate(groupId, cardId, access, (c) => { + Object.assign(c, stripUndefined(patch)); + if (logo !== undefined) { + oldLogo = c.logo; + c.logo = newLogo ?? null; + c.imagesVersion++; + } + }); + } catch (err) { + if (newLogo) await this.removeFiles(groupId, cardId, [newLogo]); + throw err; + } + if (oldLogo) await this.removeFiles(groupId, cardId, [oldLogo]); + return card; + } + + async setDeleted(groupId: string, cardId: string, deleted: boolean): Promise { + return this.mutate(groupId, cardId, { includeDeleted: true }, (c) => { + c.deleted = deleted; + }); + } + + /** Permanently removes the card and its files. */ + async purge(groupId: string, cardId: string): Promise { + await this.file(groupId).update((d) => { + const before = d.cards.length; + d.cards = d.cards.filter((c) => c.id !== cardId); + if (d.cards.length === before) throw notFound('Card not found'); + }); + await rm(this.imageDir(groupId, cardId), { recursive: true, force: true }); + } + + async addImages(groupId: string, cardId: string, uploads: Upload[], access: CardAccess): Promise { + if (uploads.length === 0) throw badRequest('No files uploaded'); + await this.get(groupId, cardId, access); + const refs: ImageRef[] = []; + try { + for (const upload of uploads) refs.push(await this.saveFile(groupId, cardId, upload)); + return await this.mutate(groupId, cardId, access, (c) => { + c.images.push(...refs); + c.imagesVersion++; + }); + } catch (err) { + await this.removeFiles(groupId, cardId, refs); + throw err; + } + } + + async removeImage(groupId: string, cardId: string, imageId: string, access: CardAccess): Promise { + let removed: ImageRef | undefined; + const card = await this.mutate(groupId, cardId, access, (c) => { + removed = c.images.find((i) => i.id === imageId); + if (!removed) throw notFound('Image not found'); + c.images = c.images.filter((i) => i !== removed); + c.imagesVersion++; + }); + if (removed) await this.removeFiles(groupId, cardId, [removed]); + return card; + } + + async removeGroup(groupId: string): Promise { + this.files.delete(groupId); + await rm(this.groupDir(groupId), { recursive: true, force: true }); + } + + private mutate(groupId: string, cardId: string, access: CardAccess, fn: (card: Card) => void): Promise { + return this.file(groupId).update((d) => { + const card = d.cards.find((c) => c.id === cardId); + if (!card || (card.deleted && !access.includeDeleted)) throw notFound('Card not found'); + fn(card); + card.updatedAt = new Date().toISOString(); + return card; + }); + } + + private async saveFile(groupId: string, cardId: string, upload: Upload): Promise { + const id = randomUUID(); + const file = `${id}.${extensionFor(upload)}`; + const dir = this.imageDir(groupId, cardId); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, file), upload.data); + return { id, file, mime: upload.mime || 'application/octet-stream' }; + } + + private async removeFiles(groupId: string, cardId: string, refs: ImageRef[]): Promise { + const dir = this.imageDir(groupId, cardId); + await Promise.all(refs.map((r) => unlink(path.join(dir, r.file)).catch(() => undefined))); + } +} + +function stripUndefined(obj: T): Partial { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial; +} diff --git a/server-be/src/config.ts b/server-be/src/config.ts new file mode 100644 index 0000000..e906a65 --- /dev/null +++ b/server-be/src/config.ts @@ -0,0 +1,61 @@ +import path from 'node:path'; + +export interface Config { + dataDir: string; + keySecret: Buffer; + superadminEmail: string; + superadminGroup: string; + /** + * Public base URL of the API for key QR codes, e.g. https://dev.aggtaa.com/api. + * When unset it is derived per request from X-Forwarded-Proto/Host/Prefix. + */ + publicApiUrl?: string; + /** When set, admin API requests must carry it in X-Cardy-Proxy-Secret (added by the reverse proxy). */ + adminProxySecret?: string; + host: string; + port: number; +} + +/** + * The reverse proxy strips "/api": public /api/admin/* (behind Authelia) arrives here as /admin/*, + * the rest of /api/* (client API, no Authelia) arrives at the root. + */ +export const ADMIN_API_PREFIX = '/admin'; + +export function parseKeySecret(value: string | undefined): Buffer { + if (!value) throw new Error('KEY_ENCRYPTION_SECRET is required'); + const buf = Buffer.from(value, 'base64'); + if (buf.length !== 32 || buf.toString('base64') !== value.trim()) { + throw new Error('KEY_ENCRYPTION_SECRET must be exactly 32 bytes encoded in base64'); + } + return buf; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { + const superadminEmail = env.SUPERADMIN_EMAIL?.trim().toLowerCase(); + if (!superadminEmail) throw new Error('SUPERADMIN_EMAIL is required'); + + const publicApiUrl = env.PUBLIC_API_URL?.trim().replace(/\/+$/, '') || undefined; + if (publicApiUrl) { + try { + new URL(publicApiUrl); + } catch { + throw new Error(`PUBLIC_API_URL is not a valid URL: ${publicApiUrl}`); + } + } + + const port = Number(env.PORT ?? 10002); + if (!Number.isInteger(port) || port <= 0) throw new Error(`Invalid PORT: ${env.PORT}`); + + return { + dataDir: path.resolve(env.DATA_DIR ?? './data'), + keySecret: parseKeySecret(env.KEY_ENCRYPTION_SECRET), + superadminEmail, + superadminGroup: env.SUPERADMIN_GROUP?.trim() || 'default', + publicApiUrl, + adminProxySecret: env.ADMIN_PROXY_SECRET?.trim() || undefined, + // Loopback by default: the admin API trusts Remote-Email, so only the local reverse proxy may connect. + host: env.HOST ?? '127.0.0.1', + port, + }; +} diff --git a/server-be/src/crypto.ts b/server-be/src/crypto.ts new file mode 100644 index 0000000..a9b05a1 --- /dev/null +++ b/server-be/src/crypto.ts @@ -0,0 +1,29 @@ +import { createCipheriv, createDecipheriv, randomBytes, randomInt } from 'node:crypto'; + +const KEY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +export const ACCESS_KEY_LENGTH = 32; + +const IV_LENGTH = 12; +const TAG_LENGTH = 16; + +export function generateAccessKey(): string { + let key = ''; + for (let i = 0; i < ACCESS_KEY_LENGTH; i++) key += KEY_ALPHABET[randomInt(KEY_ALPHABET.length)]; + return key; +} + +/** AES-256-GCM; result is base64(iv | authTag | ciphertext). */ +export function encryptSecret(plain: string, secret: Buffer): string { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv('aes-256-gcm', secret, iv); + const ciphertext = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64'); +} + +export function decryptSecret(encrypted: string, secret: Buffer): string { + const buf = Buffer.from(encrypted, 'base64'); + if (buf.length < IV_LENGTH + TAG_LENGTH) throw new Error('Encrypted value is too short'); + const decipher = createDecipheriv('aes-256-gcm', secret, buf.subarray(0, IV_LENGTH)); + decipher.setAuthTag(buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH)); + return Buffer.concat([decipher.update(buf.subarray(IV_LENGTH + TAG_LENGTH)), decipher.final()]).toString('utf8'); +} diff --git a/server-be/src/errors.ts b/server-be/src/errors.ts new file mode 100644 index 0000000..7aa674f --- /dev/null +++ b/server-be/src/errors.ts @@ -0,0 +1,14 @@ +export class HttpError extends Error { + constructor( + readonly statusCode: number, + message: string, + ) { + super(message); + } +} + +export const badRequest = (message: string) => new HttpError(400, message); +export const unauthorized = (message = 'Unauthorized') => new HttpError(401, message); +export const forbidden = (message = 'Forbidden') => new HttpError(403, message); +export const notFound = (message = 'Not found') => new HttpError(404, message); +export const conflict = (message: string) => new HttpError(409, message); diff --git a/server-be/src/forwarded.ts b/server-be/src/forwarded.ts new file mode 100644 index 0000000..8586125 --- /dev/null +++ b/server-be/src/forwarded.ts @@ -0,0 +1,18 @@ +import type { FastifyRequest } from 'fastify'; + +/** + * Path prefix the reverse proxy stripped before forwarding (X-Forwarded-Prefix), e.g. "/api". + * Returns '' when absent or malformed. + */ +export function forwardedPrefix(request: FastifyRequest): string { + const raw = request.headers['x-forwarded-prefix']; + const value = (Array.isArray(raw) ? raw[0] : raw)?.split(',')[0]?.trim().replace(/\/+$/, '') ?? ''; + return /^(\/[A-Za-z0-9._~-]+)*$/.test(value) ? value : ''; +} + +/** Public base URL of this server as seen by clients, e.g. https://dev.aggtaa.com/api */ +export function publicBaseUrl(request: FastifyRequest, override?: string): string { + if (override) return override; + // With trustProxy, protocol/host come from X-Forwarded-Proto / X-Forwarded-Host. + return `${request.protocol}://${request.host}${forwardedPrefix(request)}`; +} diff --git a/server-be/src/http.ts b/server-be/src/http.ts new file mode 100644 index 0000000..a7ab8d3 --- /dev/null +++ b/server-be/src/http.ts @@ -0,0 +1,56 @@ +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { ZodError, type ZodType } from 'zod'; +import type { Upload } from './cards.js'; +import { badRequest, notFound } from './errors.js'; + +export function parseBody(schema: ZodType, value: unknown): T { + const result = schema.safeParse(value ?? {}); + if (!result.success) throw badRequest(formatZodError(result.error)); + return result.data; +} + +export function formatZodError(error: ZodError): string { + return error.issues.map((i) => (i.path.length ? `${i.path.join('.')}: ${i.message}` : i.message)).join('; '); +} + +export interface MultipartData { + fields: Record; + files: Map; +} + +export async function readMultipart(request: FastifyRequest): Promise { + if (!request.isMultipart()) throw badRequest('Expected multipart/form-data'); + const fields: Record = {}; + const files = new Map(); + for await (const part of request.parts()) { + if (part.type === 'file') { + const data = await part.toBuffer(); + if (data.length === 0 && !part.filename) continue; // empty + const list = files.get(part.fieldname) ?? []; + list.push({ data, mime: part.mimetype, filename: part.filename }); + files.set(part.fieldname, list); + } else { + fields[part.fieldname] = String(part.value); + } + } + return { fields, files }; +} + +/** Sends a stored user upload; non-images are forced to download and nothing is ever rendered as active content. */ +export async function sendImage(reply: FastifyReply, file: { path: string; mime: string }) { + let size: number; + try { + size = (await stat(file.path)).size; + } catch { + throw notFound('Image file is missing'); + } + return reply + .header('Content-Type', file.mime.toLowerCase().startsWith('image/') ? file.mime : 'application/octet-stream') + .header('Content-Length', size) + .header('X-Content-Type-Options', 'nosniff') + .header('Content-Security-Policy', 'sandbox') + .header('Cache-Control', 'private, no-cache') + .send(createReadStream(file.path)); +} diff --git a/server-be/src/index.ts b/server-be/src/index.ts new file mode 100644 index 0000000..a80a9fc --- /dev/null +++ b/server-be/src/index.ts @@ -0,0 +1,23 @@ +import { buildApp } from './app.js'; +import { loadConfig } from './config.js'; + +const config = loadConfig(); +const app = await buildApp(config, { logger: true }); + +app.log.info( + { dataDir: config.dataDir, publicApiUrl: config.publicApiUrl ?? '(from X-Forwarded-* headers)' }, + 'Cardy server configuration', +); +if (!config.adminProxySecret && !['127.0.0.1', '::1', 'localhost'].includes(config.host)) { + app.log.warn( + `Listening on ${config.host} without ADMIN_PROXY_SECRET: anyone who can reach this port can impersonate users via Remote-Email`, + ); +} + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + app.close().then(() => process.exit(0)); + }); +} + +await app.listen({ host: config.host, port: config.port }); diff --git a/server-be/src/routes/adminApi.ts b/server-be/src/routes/adminApi.ts new file mode 100644 index 0000000..e0c419c --- /dev/null +++ b/server-be/src/routes/adminApi.ts @@ -0,0 +1,241 @@ +import { timingSafeEqual } from 'node:crypto'; +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import { + cardFieldsSchema, + cardPatchSchema, + currentGroupSchema, + groupInputSchema, + keyCreateSchema, + userCreateSchema, + userPatchSchema, + type Group, + type MeResponse, +} from '@cardy/shared'; +import type { CardsService } from '../cards.js'; +import { ADMIN_API_PREFIX } from '../config.js'; +import { forwardedPrefix, publicBaseUrl } from '../forwarded.js'; +import { badRequest, forbidden, notFound, unauthorized } from '../errors.js'; +import { parseBody, readMultipart, sendImage } from '../http.js'; +import { requireGroupAdmin, requireSuperadmin, type StoredUser, type UsersService } from '../users.js'; + +export const GROUP_COOKIE = 'cardy_group'; +export const CSRF_HEADER = 'x-cardy-admin'; +export const PROXY_SECRET_HEADER = 'x-cardy-proxy-secret'; + +export interface AdminContext { + user: StoredUser; + ownGroup: Group; + currentGroup: Group; +} + +declare module 'fastify' { + interface FastifyRequest { + admin: AdminContext; + } +} + +interface Options { + users: UsersService; + cards: CardsService; + proxySecret?: string; + publicApiUrl?: string; +} + +function safeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + return ab.length === bb.length && timingSafeEqual(ab, bb); +} + +const ADMIN_ACCESS = { includeDeleted: true }; + +type IdParams = { id: string }; +type ImageParams = { id: string; imageId: string }; +type EmailParams = { email: string }; +type KeyParams = { email: string; keyId: string }; + +export const adminApi: FastifyPluginAsync = async (app, { users, cards, proxySecret, publicApiUrl }) => { + app.decorateRequest('admin', null as unknown as AdminContext); + + const apiUrl = (request: FastifyRequest) => publicBaseUrl(request, publicApiUrl); + + app.addHook('onRequest', async (request) => { + // Only the reverse proxy route that goes through forward-auth may reach the admin API. + if (proxySecret) { + const given = request.headers[PROXY_SECRET_HEADER]; + if (typeof given !== 'string' || !safeEqual(given, proxySecret)) { + throw unauthorized(`Missing or wrong ${PROXY_SECRET_HEADER} header (ADMIN_PROXY_SECRET is set on the server)`); + } + } + // Header-based CSRF guard: cross-site forms cannot set custom headers. + if (request.method !== 'GET' && request.method !== 'HEAD' && request.headers[CSRF_HEADER] !== '1') { + throw forbidden(`Missing ${CSRF_HEADER} header`); + } + const email = request.headers['remote-email']; + if (typeof email !== 'string' || !email.trim()) throw unauthorized('Missing Remote-Email header from the authentication proxy'); + const user = await users.findUser(email); + if (!user) throw unauthorized(`User ${email.trim().toLowerCase()} is not registered in Cardy. Ask an administrator for access.`); + const ownGroup = await users.getGroup(user.groupId); + if (!ownGroup) throw unauthorized('User group does not exist'); + let currentGroup = ownGroup; + const cookieGroupId = request.cookies[GROUP_COOKIE]; + if (user.role === 'superadmin' && cookieGroupId && cookieGroupId !== ownGroup.id) { + currentGroup = (await users.getGroup(cookieGroupId)) ?? ownGroup; + } + request.admin = { user, ownGroup, currentGroup }; + }); + + // Cookie path as the browser sees it, e.g. /api/admin. + const setGroupCookie = (request: FastifyRequest, reply: FastifyReply, groupId: string) => + reply.setCookie(GROUP_COOKIE, groupId, { + path: forwardedPrefix(request) + ADMIN_API_PREFIX, + httpOnly: true, + sameSite: 'strict', + secure: request.protocol === 'https', + maxAge: 60 * 60 * 24 * 365, + }); + + const groupAdmin = (request: FastifyRequest) => { + const { user, currentGroup } = request.admin; + requireGroupAdmin(user, currentGroup.id); + return { user, groupId: currentGroup.id }; + }; + + // ---- current user ---- + + app.get('/me', async (request, reply): Promise => { + const { user, ownGroup, currentGroup } = request.admin; + if (request.cookies[GROUP_COOKIE] !== currentGroup.id) setGroupCookie(request, reply, currentGroup.id); + return { email: user.email, role: user.role, group: ownGroup, currentGroup }; + }); + + app.get('/my-keys', async (request) => users.keyViews(request.admin.user, apiUrl(request))); + + // ---- groups ---- + + app.get('/groups', async (request) => { + requireSuperadmin(request.admin.user); + return users.listGroups(); + }); + + app.post('/groups', async (request) => { + const { name } = parseBody(groupInputSchema, request.body); + return users.createGroup(request.admin.user, name); + }); + + app.patch<{ Params: IdParams }>('/groups/:id', async (request) => { + const { name } = parseBody(groupInputSchema, request.body); + return users.renameGroup(request.admin.user, request.params.id, name); + }); + + app.delete<{ Params: IdParams }>('/groups/:id', async (request, reply) => { + await users.deleteGroup(request.admin.user, request.params.id); + await cards.removeGroup(request.params.id); + return reply.code(204).send(); + }); + + app.put('/current-group', async (request, reply) => { + requireSuperadmin(request.admin.user); + const { groupId } = parseBody(currentGroupSchema, request.body); + const group = await users.getGroup(groupId); + if (!group) throw notFound('Group not found'); + setGroupCookie(request, reply, group.id); + return group; + }); + + // ---- users of the current group ---- + + app.get('/users', async (request) => { + const { user, groupId } = groupAdmin(request); + return (await users.listUsers(user, groupId)).map((u) => users.userView(u, apiUrl(request))); + }); + + app.post('/users', async (request, reply) => { + const { user, groupId } = groupAdmin(request); + const created = await users.createUser(user, groupId, parseBody(userCreateSchema, request.body)); + return reply.code(201).send(users.userView(created, apiUrl(request))); + }); + + app.patch<{ Params: EmailParams }>('/users/:email', async (request) => { + const { user, groupId } = groupAdmin(request); + const updated = await users.updateUser(user, groupId, request.params.email, parseBody(userPatchSchema, request.body)); + return users.userView(updated, apiUrl(request)); + }); + + app.delete<{ Params: EmailParams }>('/users/:email', async (request, reply) => { + const { user, groupId } = groupAdmin(request); + await users.deleteUser(user, groupId, request.params.email); + return reply.code(204).send(); + }); + + app.post<{ Params: EmailParams }>('/users/:email/keys', async (request, reply) => { + const { user, groupId } = groupAdmin(request); + const { label } = parseBody(keyCreateSchema, request.body); + const key = await users.createKey(user, groupId, request.params.email, label || undefined, apiUrl(request)); + return reply.code(201).send(key); + }); + + app.delete<{ Params: KeyParams }>('/users/:email/keys/:keyId', async (request, reply) => { + const { user, groupId } = groupAdmin(request); + await users.deleteKey(user, groupId, request.params.email, request.params.keyId); + return reply.code(204).send(); + }); + + // ---- cards of the current group ---- + + app.get('/cards', async (request) => cards.list(groupAdmin(request).groupId, ADMIN_ACCESS)); + + app.post('/cards', async (request, reply) => { + const { groupId } = groupAdmin(request); + return reply.code(201).send(await cards.create(groupId, parseBody(cardFieldsSchema, request.body))); + }); + + app.patch<{ Params: IdParams }>('/cards/:id', async (request) => { + const { groupId } = groupAdmin(request); + return cards.update(groupId, request.params.id, parseBody(cardPatchSchema, request.body), ADMIN_ACCESS); + }); + + /** First delete marks the card as deleted, deleting an already deleted card removes it permanently. */ + app.delete<{ Params: IdParams }>('/cards/:id', async (request, reply) => { + const { groupId } = groupAdmin(request); + const card = await cards.get(groupId, request.params.id, ADMIN_ACCESS); + if (!card.deleted) return cards.setDeleted(groupId, card.id, true); + await cards.purge(groupId, card.id); + return reply.code(204).send(); + }); + + app.get<{ Params: IdParams }>('/cards/:id/logo', async (request, reply) => { + const { groupId } = groupAdmin(request); + return sendImage(reply, await cards.imagePath(groupId, request.params.id, 'logo', ADMIN_ACCESS)); + }); + + app.put<{ Params: IdParams }>('/cards/:id/logo', async (request) => { + const { groupId } = groupAdmin(request); + const { files } = await readMultipart(request); + const logo = files.get('file')?.[0]; + if (!logo) throw badRequest('No file uploaded'); + return cards.update(groupId, request.params.id, {}, ADMIN_ACCESS, logo); + }); + + app.delete<{ Params: IdParams }>('/cards/:id/logo', async (request) => { + const { groupId } = groupAdmin(request); + return cards.update(groupId, request.params.id, {}, ADMIN_ACCESS, null); + }); + + app.get<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request, reply) => { + const { groupId } = groupAdmin(request); + const { id, imageId } = request.params; + return sendImage(reply, await cards.imagePath(groupId, id, imageId, ADMIN_ACCESS)); + }); + + app.post<{ Params: IdParams }>('/cards/:id/images', async (request) => { + const { groupId } = groupAdmin(request); + const { files } = await readMultipart(request); + return cards.addImages(groupId, request.params.id, files.get('file') ?? [], ADMIN_ACCESS); + }); + + app.delete<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request) => { + const { groupId } = groupAdmin(request); + return cards.removeImage(groupId, request.params.id, request.params.imageId, ADMIN_ACCESS); + }); +}; diff --git a/server-be/src/routes/clientApi.ts b/server-be/src/routes/clientApi.ts new file mode 100644 index 0000000..c50f950 --- /dev/null +++ b/server-be/src/routes/clientApi.ts @@ -0,0 +1,95 @@ +import cors, { type FastifyCorsOptions } from '@fastify/cors'; +import type { FastifyPluginAsync, FastifyRequest } from 'fastify'; +import { cardFieldsSchema, type ApiAuthResponse } from '@cardy/shared'; +import type { CardsService } from '../cards.js'; +import { unauthorized } from '../errors.js'; +import { parseBody, readMultipart, sendImage } from '../http.js'; +import type { KeyOwner, UsersService } from '../users.js'; + +declare module 'fastify' { + interface FastifyRequest { + keyOwner: KeyOwner; + } +} + +interface Options { + users: UsersService; + cards: CardsService; +} + +const CLIENT_ACCESS = { includeDeleted: false }; + +type IdParams = { id: string }; +type ImageParams = { id: string; imageId: string }; + +export const clientApi: FastifyPluginAsync = async (app, { users, cards }) => { + // Capacitor apps run on origin https://localhost; auth is a bearer key, never cookies. + // The plugin's catch-all OPTIONS route would also match admin paths, so CORS is off there. + const corsOptions: FastifyCorsOptions = { + origin: true, + methods: ['GET', 'POST', 'PUT', 'DELETE'], + allowedHeaders: ['Authorization', 'Content-Type'], + maxAge: 86400, + }; + await app.register(cors, () => (request: FastifyRequest, callback: (err: Error | null, options: FastifyCorsOptions) => void) => { + const isAdmin = /^\/admin(\/|\?|$)/.test(request.url ?? ''); + callback(null, isAdmin ? { origin: false } : corsOptions); + }); + + app.decorateRequest('keyOwner', null as unknown as KeyOwner); + + app.addHook('preHandler', async (request) => { + if (request.method === 'OPTIONS') return; + const match = /^Bearer\s+(\S+)\s*$/i.exec(request.headers.authorization ?? ''); + const owner = match?.[1] ? await users.authenticateKey(match[1]) : undefined; + if (!owner) throw unauthorized('Invalid access key'); + request.keyOwner = owner; + }); + + const groupId = (request: { keyOwner: KeyOwner }) => request.keyOwner.group.id; + + app.get('/auth', async (request): Promise => { + const { user, group } = request.keyOwner; + return { email: user.email, group }; + }); + + app.get('/cards', async (request) => cards.list(groupId(request), CLIENT_ACCESS)); + + app.post('/cards', async (request, reply) => { + const { fields, files } = await readMultipart(request); + const card = await cards.create(groupId(request), parseBody(cardFieldsSchema, fields), files.get('logo')?.[0]); + return reply.code(201).send(card); + }); + + app.put<{ Params: IdParams }>('/cards/:id', async (request) => { + const { fields, files } = await readMultipart(request); + const { removeLogo, ...cardFields } = fields; + const patch = parseBody(cardFieldsSchema.partial(), cardFields); + const logo = files.get('logo')?.[0] ?? (removeLogo === 'true' ? null : undefined); + return cards.update(groupId(request), request.params.id, patch, CLIENT_ACCESS, logo); + }); + + app.delete<{ Params: IdParams }>('/cards/:id', async (request, reply) => { + const card = await cards.get(groupId(request), request.params.id, CLIENT_ACCESS); + await cards.setDeleted(groupId(request), card.id, true); + return reply.code(204).send(); + }); + + app.get<{ Params: IdParams }>('/cards/:id/logo', async (request, reply) => + sendImage(reply, await cards.imagePath(groupId(request), request.params.id, 'logo', CLIENT_ACCESS)), + ); + + app.get<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request, reply) => { + const { id, imageId } = request.params; + return sendImage(reply, await cards.imagePath(groupId(request), id, imageId, CLIENT_ACCESS)); + }); + + app.post<{ Params: IdParams }>('/cards/:id/images', async (request) => { + const { files } = await readMultipart(request); + return cards.addImages(groupId(request), request.params.id, files.get('file') ?? [], CLIENT_ACCESS); + }); + + app.delete<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request) => + cards.removeImage(groupId(request), request.params.id, request.params.imageId, CLIENT_ACCESS), + ); +}; diff --git a/server-be/src/users.ts b/server-be/src/users.ts new file mode 100644 index 0000000..80b70fc --- /dev/null +++ b/server-be/src/users.ts @@ -0,0 +1,279 @@ +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import { z } from 'zod'; +import { ROLES, buildLoginDeepLink, type AccessKeyView, type Group, type Role, type UserView } from '@cardy/shared'; +import { decryptSecret, encryptSecret, generateAccessKey } from './crypto.js'; +import { badRequest, conflict, forbidden, notFound } from './errors.js'; +import { YamlFile } from './yamlFile.js'; + +const idSchema = z.string().regex(/^[A-Za-z0-9-]+$/, 'Invalid id'); + +const storedKeySchema = z.object({ + id: idSchema, + label: z.string().optional(), + createdAt: z.string(), + encrypted: z.string(), +}); + +const storedUserSchema = z.object({ + email: z.string().transform((s) => s.trim().toLowerCase()), + groupId: idSchema, + role: z.enum(ROLES), + keys: z.array(storedKeySchema).default([]), +}); + +const usersFileSchema = z.object({ + groups: z.array(z.object({ id: idSchema, name: z.string() })).default([]), + users: z.array(storedUserSchema).default([]), +}); + +export type StoredKey = z.infer; +export type StoredUser = z.infer; +type UsersFile = z.infer; + +export interface KeyOwner { + user: StoredUser; + group: Group; +} + +export class UsersService { + private readonly file: YamlFile; + /** Plaintext key → key owner email; rebuilt when the file data object changes. */ + private keyIndex: { source: UsersFile; map: Map } | undefined; + + constructor( + dataDir: string, + private readonly secret: Buffer, + ) { + this.file = new YamlFile( + path.join(dataDir, 'users.yaml'), + (raw) => usersFileSchema.parse(raw ?? {}), + () => ({ groups: [], users: [] }), + ); + } + + /** Creates the configured superadmin (and their group) if missing, and enforces the superadmin role. */ + async ensureSuperadmin(email: string, groupName: string): Promise { + const data = await this.file.read(); + const existing = data.users.find((u) => u.email === email); + if (existing?.role === 'superadmin') return; + await this.file.update((d) => { + const user = d.users.find((u) => u.email === email); + if (user) { + user.role = 'superadmin'; + return; + } + let group = d.groups.find((g) => sameName(g.name, groupName)); + if (!group) { + group = { id: randomUUID(), name: groupName }; + d.groups.push(group); + } + d.users.push({ email, groupId: group.id, role: 'superadmin', keys: [] }); + }); + } + + // ---- lookups ---- + + async findUser(email: string): Promise { + const normalized = email.trim().toLowerCase(); + return (await this.file.read()).users.find((u) => u.email === normalized); + } + + async getGroup(id: string): Promise { + return (await this.file.read()).groups.find((g) => g.id === id); + } + + async listGroups(): Promise { + return [...(await this.file.read()).groups].sort((a, b) => a.name.localeCompare(b.name)); + } + + async authenticateKey(key: string): Promise { + const data = await this.file.read(); + if (this.keyIndex?.source !== data) { + const map = new Map(); + for (const user of data.users) { + for (const k of user.keys) map.set(decryptSecret(k.encrypted, this.secret), user.email); + } + this.keyIndex = { source: data, map }; + } + const email = this.keyIndex.map.get(key); + const user = email ? data.users.find((u) => u.email === email) : undefined; + const group = user ? data.groups.find((g) => g.id === user.groupId) : undefined; + return user && group ? { user, group } : undefined; + } + + // ---- views ---- + + /** `apiUrl` is the public API base URL that goes into login deep links. */ + keyViews(user: StoredUser, apiUrl: string): AccessKeyView[] { + return user.keys.map((k) => { + const key = decryptSecret(k.encrypted, this.secret); + return { id: k.id, label: k.label, createdAt: k.createdAt, key, deepLink: buildLoginDeepLink(apiUrl, key) }; + }); + } + + userView(user: StoredUser, apiUrl: string): UserView { + return { email: user.email, groupId: user.groupId, role: user.role, keys: this.keyViews(user, apiUrl) }; + } + + // ---- groups (superadmin) ---- + + async createGroup(actor: StoredUser, name: string): Promise { + requireSuperadmin(actor); + return this.file.update((d) => { + if (d.groups.some((g) => sameName(g.name, name))) throw conflict(`Group "${name}" already exists`); + const group = { id: randomUUID(), name }; + d.groups.push(group); + return group; + }); + } + + async renameGroup(actor: StoredUser, id: string, name: string): Promise { + requireSuperadmin(actor); + return this.file.update((d) => { + const group = d.groups.find((g) => g.id === id); + if (!group) throw notFound('Group not found'); + if (d.groups.some((g) => g.id !== id && sameName(g.name, name))) throw conflict(`Group "${name}" already exists`); + group.name = name; + return group; + }); + } + + /** Removes the group with its users and keys. The caller removes the group's cards. */ + async deleteGroup(actor: StoredUser, id: string): Promise { + requireSuperadmin(actor); + await this.file.update((d) => { + if (!d.groups.some((g) => g.id === id)) throw notFound('Group not found'); + if (d.users.some((u) => u.groupId === id && u.role === 'superadmin')) { + throw conflict('A group that contains a superadmin cannot be deleted'); + } + d.groups = d.groups.filter((g) => g.id !== id); + d.users = d.users.filter((u) => u.groupId !== id); + }); + } + + // ---- users (admin of the current group) ---- + + async listUsers(actor: StoredUser, groupId: string): Promise { + requireGroupAdmin(actor, groupId); + const data = await this.file.read(); + return data.users.filter((u) => u.groupId === groupId).sort((a, b) => a.email.localeCompare(b.email)); + } + + async createUser( + actor: StoredUser, + currentGroupId: string, + input: { email: string; role: Role; groupId?: string }, + ): Promise { + const groupId = input.groupId ?? currentGroupId; + if (groupId !== currentGroupId) requireSuperadmin(actor); + requireGroupAdmin(actor, groupId); + if (input.role === 'superadmin') requireSuperadmin(actor); + return this.file.update((d) => { + if (!d.groups.some((g) => g.id === groupId)) throw notFound('Group not found'); + if (d.users.some((u) => u.email === input.email)) throw conflict(`User ${input.email} already exists`); + const user: StoredUser = { email: input.email, groupId, role: input.role, keys: [] }; + d.users.push(user); + return user; + }); + } + + async updateUser( + actor: StoredUser, + currentGroupId: string, + email: string, + patch: { role?: Role; groupId?: string }, + ): Promise { + if (patch.role === 'superadmin') requireSuperadmin(actor); + if (patch.groupId !== undefined) requireSuperadmin(actor); + return this.file.update((d) => { + const user = findManagedUser(d, actor, currentGroupId, email); + if (patch.role !== undefined && patch.role !== user.role) { + if (user.email === actor.email) throw badRequest('You cannot change your own role'); + if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot demote the last superadmin'); + user.role = patch.role; + } + if (patch.groupId !== undefined && patch.groupId !== user.groupId) { + if (!d.groups.some((g) => g.id === patch.groupId)) throw notFound('Group not found'); + user.groupId = patch.groupId; + } + return user; + }); + } + + async deleteUser(actor: StoredUser, currentGroupId: string, email: string): Promise { + await this.file.update((d) => { + const user = findManagedUser(d, actor, currentGroupId, email); + if (user.email === actor.email) throw badRequest('You cannot delete yourself'); + if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot delete the last superadmin'); + d.users = d.users.filter((u) => u !== user); + }); + } + + // ---- keys ---- + + async createKey( + actor: StoredUser, + currentGroupId: string, + email: string, + label: string | undefined, + apiUrl: string, + ): Promise { + const plain = generateAccessKey(); + const stored: StoredKey = { + id: randomUUID(), + ...(label ? { label } : {}), + createdAt: new Date().toISOString(), + encrypted: encryptSecret(plain, this.secret), + }; + await this.file.update((d) => { + findManagedUser(d, actor, currentGroupId, email).keys.push(stored); + }); + return { + id: stored.id, + label: stored.label, + createdAt: stored.createdAt, + key: plain, + deepLink: buildLoginDeepLink(apiUrl, plain), + }; + } + + async deleteKey(actor: StoredUser, currentGroupId: string, email: string, keyId: string): Promise { + await this.file.update((d) => { + const user = findManagedUser(d, actor, currentGroupId, email); + const before = user.keys.length; + user.keys = user.keys.filter((k) => k.id !== keyId); + if (user.keys.length === before) throw notFound('Key not found'); + }); + } +} + +function sameName(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +function countSuperadmins(d: UsersFile): number { + return d.users.filter((u) => u.role === 'superadmin').length; +} + +export function isGroupAdmin(actor: StoredUser, groupId: string): boolean { + return actor.role === 'superadmin' || (actor.role === 'admin' && actor.groupId === groupId); +} + +export function requireGroupAdmin(actor: StoredUser, groupId: string): void { + if (!isGroupAdmin(actor, groupId)) throw forbidden(); +} + +export function requireSuperadmin(actor: StoredUser): void { + if (actor.role !== 'superadmin') throw forbidden(); +} + +/** A user of the current group that the actor may manage (only superadmins manage superadmins). */ +function findManagedUser(d: UsersFile, actor: StoredUser, currentGroupId: string, email: string): StoredUser { + requireGroupAdmin(actor, currentGroupId); + const normalized = email.trim().toLowerCase(); + const user = d.users.find((u) => u.email === normalized && u.groupId === currentGroupId); + if (!user) throw notFound('User not found'); + if (user.role === 'superadmin') requireSuperadmin(actor); + return user; +} diff --git a/server-be/src/yamlFile.ts b/server-be/src/yamlFile.ts new file mode 100644 index 0000000..97b4fdc --- /dev/null +++ b/server-be/src/yamlFile.ts @@ -0,0 +1,81 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import YAML from 'yaml'; + +/** + * A YAML document on disk with an in-memory cache. + * - Reads are served from cache until the file's mtime changes (manual edits are picked up). + * - Updates are serialized and written atomically (temp file + rename). + * - Callers must treat data returned by read() as immutable. + */ +export class YamlFile { + private cache: { mtimeMs: number; data: T } | undefined; + private queue: Promise = Promise.resolve(); + + constructor( + readonly filePath: string, + private readonly parse: (raw: unknown) => T, + private readonly empty: () => T, + ) {} + + async read(): Promise { + let mtimeMs: number; + try { + mtimeMs = (await stat(this.filePath)).mtimeMs; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.empty(); + throw err; + } + if (this.cache?.mtimeMs === mtimeMs) return this.cache.data; + const text = await readFile(this.filePath, 'utf8'); + let data: T; + try { + data = this.parse(YAML.parse(text) ?? undefined); + } catch (err) { + throw new Error(`Invalid data in ${this.filePath}: ${(err as Error).message}`); + } + this.cache = { mtimeMs, data }; + return data; + } + + /** Runs `fn` on a copy of the data and saves the result; if `fn` throws, nothing is written. */ + update(fn: (data: T) => R | Promise): Promise { + const run = async () => { + const data = structuredClone(await this.read()); + const result = await fn(data); + await this.write(data); + return result; + }; + const next = this.queue.then(run, run); + this.queue = next.catch(() => undefined); + return next; + } + + private async write(data: T): Promise { + await mkdir(path.dirname(this.filePath), { recursive: true }); + const tmp = `${this.filePath}.${randomBytes(6).toString('hex')}.tmp`; + await writeFile(tmp, YAML.stringify(data), 'utf8'); + try { + await renameWithRetry(tmp, this.filePath); + } catch (err) { + await unlink(tmp).catch(() => undefined); + throw err; + } + this.cache = { mtimeMs: (await stat(this.filePath)).mtimeMs, data }; + } +} + +// On Windows a rename over a file that is momentarily open elsewhere fails with EPERM/EBUSY. +async function renameWithRetry(from: string, to: string): Promise { + for (let attempt = 0; ; attempt++) { + try { + await rename(from, to); + return; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (attempt >= 5 || (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES')) throw err; + await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1))); + } + } +} diff --git a/server-be/test/adminApi.test.ts b/server-be/test/adminApi.test.ts new file mode 100644 index 0000000..7ca76c6 --- /dev/null +++ b/server-be/test/adminApi.test.ts @@ -0,0 +1,232 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import type { FastifyInstance } from 'fastify'; +import { describe, expect, it } from 'vitest'; +import type { AccessKeyView, Card, Group, MeResponse, UserView } from '@cardy/shared'; +import { SUPERADMIN, multipart, testApp } from './helpers.js'; + +type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + +function admin(app: FastifyInstance, email: string, cookie?: string) { + return async (method: Method, url: string, body?: unknown) => { + const res = await app.inject({ + method, + url: `/admin${url}`, + headers: { 'remote-email': email, 'x-cardy-admin': '1', ...(cookie ? { cookie } : {}) }, + ...(body !== undefined ? { payload: body as object } : {}), + }); + const isJson = String(res.headers['content-type'] ?? '').startsWith('application/json'); + return { status: res.statusCode, body: isJson ? res.json() : undefined, res }; + }; +} + +async function setup() { + const { app, config } = await testApp(); + const root = admin(app, SUPERADMIN); + const me = (await root('GET', '/me')).body as MeResponse; + return { app, config, root, defaultGroup: me.group }; +} + +describe('admin API authentication', () => { + it('bootstraps the superadmin from config', async () => { + const { root } = await setup(); + const { status, body } = await root('GET', '/me'); + expect(status).toBe(200); + expect(body).toMatchObject({ email: SUPERADMIN, role: 'superadmin', group: { name: 'default' } }); + }); + + it('returns 401 without header or for unknown users', async () => { + const { app } = await setup(); + expect((await app.inject({ url: '/admin/me' })).statusCode).toBe(401); + expect((await admin(app, 'nobody@example.com')('GET', '/me')).status).toBe(401); + }); + + it('matches Remote-Email case-insensitively', async () => { + const { app } = await setup(); + expect((await admin(app, SUPERADMIN.toUpperCase())('GET', '/me')).status).toBe(200); + }); + + it('requires the CSRF header on mutations', async () => { + const { app } = await setup(); + const res = await app.inject({ + method: 'POST', + url: '/admin/groups', + headers: { 'remote-email': SUPERADMIN }, + payload: { name: 'x' }, + }); + expect(res.statusCode).toBe(403); + }); +}); + +describe('groups and current group', () => { + it('lets the superadmin switch groups via cookie', async () => { + const { app, root, defaultGroup } = await setup(); + const other = (await root('POST', '/groups', { name: 'Other' })).body as Group; + expect((await root('POST', '/groups', { name: 'other' })).status).toBe(409); + + const switched = await root('PUT', '/current-group', { groupId: other.id }); + expect(switched.status).toBe(200); + const setCookie = String(switched.res.headers['set-cookie']); + expect(setCookie).toContain(`cardy_group=${other.id}`); + expect(setCookie).toMatch(/HttpOnly/i); + + const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`); + expect(((await inOther('GET', '/me')).body as MeResponse).currentGroup.id).toBe(other.id); + // Unknown group in cookie falls back to own group. + const stale = admin(app, SUPERADMIN, 'cardy_group=missing'); + expect(((await stale('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id); + }); + + it('ignores the group cookie for non-superadmins', async () => { + const { app, root, defaultGroup } = await setup(); + const other = (await root('POST', '/groups', { name: 'Other' })).body as Group; + await root('POST', '/users', { email: 'adm@example.com', role: 'admin' }); + const adm = admin(app, 'adm@example.com', `cardy_group=${other.id}`); + expect(((await adm('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id); + expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(403); + expect((await adm('GET', '/groups')).status).toBe(403); + }); + + it('refuses to delete a group with a superadmin, deletes others with users and cards', async () => { + const { app, config, root, defaultGroup } = await setup(); + expect((await root('DELETE', `/groups/${defaultGroup.id}`)).status).toBe(409); + + const other = (await root('POST', '/groups', { name: 'Other' })).body as Group; + const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`); + await inOther('POST', '/users', { email: 'u@example.com' }); + await inOther('POST', '/cards', { name: 'Shop', color: '#112233', code: '123', codeType: 'CODE128' }); + expect(existsSync(path.join(config.dataDir, 'groups', other.id, 'cards.yaml'))).toBe(true); + + expect((await root('DELETE', `/groups/${other.id}`)).status).toBe(204); + expect(existsSync(path.join(config.dataDir, 'groups', other.id))).toBe(false); + expect((await admin(app, 'u@example.com')('GET', '/me')).status).toBe(401); + }); +}); + +describe('users and keys', () => { + it('enforces role rules', async () => { + const { app, root } = await setup(); + await root('POST', '/users', { email: 'adm@example.com', role: 'admin' }); + await root('POST', '/users', { email: 'User@Example.com' }); + const adm = admin(app, 'adm@example.com'); + const user = admin(app, 'user@example.com'); + + expect((await user('GET', '/users')).status).toBe(403); + expect((await user('GET', '/cards')).status).toBe(403); + expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(403); + expect((await adm('PATCH', `/users/${SUPERADMIN}`, { role: 'user' })).status).toBe(403); + expect((await adm('DELETE', `/users/${SUPERADMIN}`)).status).toBe(403); + expect((await adm('PATCH', '/users/adm@example.com', { role: 'user' })).status).toBe(400); + expect((await adm('DELETE', '/users/adm@example.com')).status).toBe(400); + expect((await adm('PATCH', '/users/user@example.com', { role: 'admin' })).status).toBe(200); + expect((await adm('POST', '/users', { email: 'user@example.com' })).status).toBe(409); + expect((await adm('POST', '/users', { email: 'not-an-email' })).status).toBe(400); + expect((await root('PATCH', `/users/${SUPERADMIN}`, { role: 'admin' })).status).toBe(400); + }); + + it('admin generates keys; users see their own keys with deep links', async () => { + const { app, root } = await setup(); + await root('POST', '/users', { email: 'user@example.com' }); + const created = await root('POST', '/users/user@example.com/keys', { label: 'Phone' }); + expect(created.status).toBe(201); + const key = created.body as AccessKeyView; + expect(key.key).toMatch(/^[A-Za-z0-9]{32}$/); + expect(key.deepLink).toBe(`cardy://login?server=${encodeURIComponent('https://cards.example.com/api')}&key=${key.key}`); + + const user = admin(app, 'user@example.com'); + expect((await user('POST', '/users/user@example.com/keys', {})).status).toBe(403); + expect((await user('GET', '/my-keys')).body).toEqual([key]); + + const listed = (await root('GET', '/users')).body as UserView[]; + expect(listed.find((u) => u.email === 'user@example.com')?.keys).toEqual([key]); + + expect((await root('DELETE', `/users/user@example.com/keys/${key.id}`)).status).toBe(204); + expect((await user('GET', '/my-keys')).body).toEqual([]); + }); + + it('stores keys encrypted', async () => { + const { app, config, root } = await setup(); + const key = (await root('POST', `/users/${SUPERADMIN}/keys`, {})).body as AccessKeyView; + const { readFile } = await import('node:fs/promises'); + const yaml = await readFile(path.join(config.dataDir, 'users.yaml'), 'utf8'); + expect(yaml).not.toContain(key.key); + expect(yaml).toContain('encrypted:'); + await app.close(); + }); +}); + +describe('cards', () => { + it('supports CRUD, soft delete, restore and purge', async () => { + const { root } = await setup(); + const created = await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: '4600000000000', codeType: 'EAN13' }); + expect(created.status).toBe(201); + const card = created.body as Card; + expect(card).toMatchObject({ name: 'Shop', imagesVersion: 0, deleted: false, logo: null, images: [] }); + + expect((await root('POST', '/cards', { name: 'x', color: 'red', code: '1', codeType: 'EAN13' })).status).toBe(400); + expect((await root('POST', '/cards', { name: 'x', color: '#000000', code: '1', codeType: 'NOPE' })).status).toBe(400); + + const patched = await root('PATCH', `/cards/${card.id}`, { name: 'Shop 2' }); + expect((patched.body as Card).name).toBe('Shop 2'); + + const deleted = await root('DELETE', `/cards/${card.id}`); + expect((deleted.body as Card).deleted).toBe(true); + expect(((await root('GET', '/cards')).body as Card[])[0]?.deleted).toBe(true); + + const restored = await root('PATCH', `/cards/${card.id}`, { deleted: false }); + expect((restored.body as Card).deleted).toBe(false); + + await root('DELETE', `/cards/${card.id}`); + expect((await root('DELETE', `/cards/${card.id}`)).status).toBe(204); + expect((await root('GET', '/cards')).body).toEqual([]); + }); + + it('manages logo and images with version bumps', async () => { + const { app, root } = await setup(); + const card = (await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: 'X', codeType: 'QR' })).body as Card; + const png = Buffer.from('fake-png'); + const upload = (url: string, method: 'PUT' | 'POST', parts: Parameters[0]) => { + const { payload, headers } = multipart(parts); + return app.inject({ method, url: `/admin${url}`, payload, headers: { ...headers, 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' } }); + }; + + const withLogo = (await upload(`/cards/${card.id}/logo`, 'PUT', [{ name: 'file', filename: 'l.png', mime: 'image/png', data: png }])).json() as Card; + expect(withLogo.logo?.mime).toBe('image/png'); + expect(withLogo.imagesVersion).toBe(1); + + const logoRes = await root('GET', `/cards/${card.id}/logo`); + expect(logoRes.res.rawPayload).toEqual(png); + expect(logoRes.res.headers['content-type']).toBe('image/png'); + expect(logoRes.res.headers['content-security-policy']).toBe('sandbox'); + + const withImages = ( + await upload(`/cards/${card.id}/images`, 'POST', [ + { name: 'file', filename: 'a.webp', mime: 'image/webp', data: Buffer.from('a') }, + { name: 'file', filename: 'b.html', mime: 'text/html', data: Buffer.from(' + + diff --git a/server-fe/package.json b/server-fe/package.json new file mode 100644 index 0000000..52d686e --- /dev/null +++ b/server-fe/package.json @@ -0,0 +1,28 @@ +{ + "name": "@cardy/server-fe", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "start": "vite build && vite preview", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@cardy/shared": "workspace:*", + "bwip-js": "^4.11.4", + "qrcode": "^1.5.4", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@types/qrcode": "^1.5.6", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "^7.0.2", + "vite": "^8.3.1" + } +} diff --git a/server-fe/src/App.tsx b/server-fe/src/App.tsx new file mode 100644 index 0000000..374c4cc --- /dev/null +++ b/server-fe/src/App.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react'; +import type { MeResponse } from '@cardy/shared'; +import { api, ApiError } from './api.js'; +import { ErrorText, useLoad } from './components.js'; +import { CardEditorPage } from './pages/CardEditor.js'; +import { CardsPage } from './pages/Cards.js'; +import { GroupsPage } from './pages/Groups.js'; +import { MyKeysPage } from './pages/MyKeys.js'; +import { UsersPage } from './pages/Users.js'; +import { linkClick, navigate, usePath } from './router.js'; + +export function App() { + const me = useLoad(api.me); + // Bumped when the superadmin switches groups so pages reload their data. + const [groupEpoch, setGroupEpoch] = useState(0); + + if (me.loading && !me.data) return
Loading…
; + if (!me.data) { + return ( +
+

Cardy

+

{me.error}

+
+ ); + } + return { await me.reload(); setGroupEpoch((e) => e + 1); }} />; +} + +function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch: number; onGroupChanged: () => Promise }) { + const path = usePath(); + const isAdmin = me.role !== 'user'; + const isSuper = me.role === 'superadmin'; + + const tabs = [ + ...(isAdmin ? [{ path: '/cards', label: 'Cards' }, { path: '/users', label: 'Users' }] : []), + ...(isSuper ? [{ path: '/groups', label: 'Groups' }] : []), + { path: '/keys', label: 'My keys' }, + ]; + + let page = null; + const cardMatch = /^\/cards\/([^/]+)$/.exec(path); + if (isAdmin && cardMatch) page = ; + else if (isAdmin && path === '/cards') page = ; + else if (isAdmin && path === '/users') page = ; + else if (isSuper && path === '/groups') page = ; + else if (path === '/keys') page = ; + + useEffect(() => { + if (!page) navigate(isAdmin ? '/cards' : '/keys', { replace: true }); + }); + + return ( +
+
+
Cardy
+ +
+ {isSuper ? : {me.currentGroup.name}} + + {me.email} · {me.role} + +
+
+
{page}
+
+ ); +} + +function GroupSwitcher({ me, onChanged, epoch }: { me: MeResponse; onChanged: () => Promise; epoch: number }) { + const groups = useLoad(api.groups, [epoch]); + const [error, setError] = useState(); + return ( + + ); +} diff --git a/server-fe/src/api.ts b/server-fe/src/api.ts new file mode 100644 index 0000000..cb6c5d7 --- /dev/null +++ b/server-fe/src/api.ts @@ -0,0 +1,97 @@ +import type { AccessKeyView, Card, CardFields, CardPatch, Group, MeResponse, Role, UserView } from '@cardy/shared'; + +// Reverse proxy: /api/admin/* goes through Authelia to server-be. +const BASE = '/api/admin'; +const RELOAD_MARK = 'cardy:auth-reload'; + +export class ApiError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +/** + * A 401 without Cardy's JSON body comes from Authelia (session expired): reload the page so + * Authelia shows its login form. Guarded against reload loops. + */ +function reloadForLogin(): boolean { + let last = 0; + try { + last = Number(sessionStorage.getItem(RELOAD_MARK) ?? 0); + if (Date.now() - last < 15_000) return false; + sessionStorage.setItem(RELOAD_MARK, String(Date.now())); + } catch { + return false; + } + location.reload(); + return true; +} + +async function request(method: string, path: string, body?: unknown): Promise { + // Accept/X-Requested-With make Authelia answer 401 instead of redirecting to its login page. + const headers: Record = { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }; + if (method !== 'GET') headers['X-Cardy-Admin'] = '1'; + let payload: BodyInit | undefined; + if (body instanceof FormData) payload = body; + else if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + payload = JSON.stringify(body); + } + const res = await fetch(BASE + path, { method, headers, body: payload, credentials: 'same-origin' }); + if (!res.ok) { + let cardyError: string | undefined; + try { + cardyError = ((await res.json()) as { error?: string }).error; + } catch { + // not JSON: the response came from the proxy or Authelia + } + if (res.status === 401 && cardyError === undefined && reloadForLogin()) { + await new Promise(() => undefined); // page is reloading + } + throw new ApiError(res.status, cardyError ?? `${res.status} ${res.statusText}`); + } + return (res.status === 204 ? undefined : await res.json()) as T; +} + +const enc = encodeURIComponent; + +function files(list: File[]): FormData { + const form = new FormData(); + for (const f of list) form.append('file', f, f.name); + return form; +} + +export const api = { + me: () => request('GET', '/me'), + myKeys: () => request('GET', '/my-keys'), + + groups: () => request('GET', '/groups'), + createGroup: (name: string) => request('POST', '/groups', { name }), + renameGroup: (id: string, name: string) => request('PATCH', `/groups/${enc(id)}`, { name }), + deleteGroup: (id: string) => request('DELETE', `/groups/${enc(id)}`), + setCurrentGroup: (groupId: string) => request('PUT', '/current-group', { groupId }), + + users: () => request('GET', '/users'), + createUser: (email: string, role: Role) => request('POST', '/users', { email, role }), + updateUser: (email: string, patch: { role?: Role; groupId?: string }) => + request('PATCH', `/users/${enc(email)}`, patch), + deleteUser: (email: string) => request('DELETE', `/users/${enc(email)}`), + createKey: (email: string, label: string) => request('POST', `/users/${enc(email)}/keys`, { label }), + deleteKey: (email: string, keyId: string) => request('DELETE', `/users/${enc(email)}/keys/${enc(keyId)}`), + + cards: () => request('GET', '/cards'), + createCard: (fields: CardFields) => request('POST', '/cards', fields), + updateCard: (id: string, patch: CardPatch) => request('PATCH', `/cards/${enc(id)}`, patch), + /** Marks as deleted; for an already deleted card removes it permanently (returns undefined). */ + deleteCard: (id: string) => request('DELETE', `/cards/${enc(id)}`), + setLogo: (id: string, file: File) => request('PUT', `/cards/${enc(id)}/logo`, files([file])), + removeLogo: (id: string) => request('DELETE', `/cards/${enc(id)}/logo`), + addImages: (id: string, list: File[]) => request('POST', `/cards/${enc(id)}/images`, files(list)), + removeImage: (id: string, imageId: string) => request('DELETE', `/cards/${enc(id)}/images/${enc(imageId)}`), + + logoUrl: (card: Card) => `${BASE}/cards/${enc(card.id)}/logo?v=${card.imagesVersion}`, + imageUrl: (card: Card, imageId: string) => `${BASE}/cards/${enc(card.id)}/images/${enc(imageId)}`, +}; diff --git a/server-fe/src/components.tsx b/server-fe/src/components.tsx new file mode 100644 index 0000000..111575d --- /dev/null +++ b/server-fe/src/components.tsx @@ -0,0 +1,214 @@ +import QRCode from 'qrcode'; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { + BWIP_BCID, + TWO_DIMENSIONAL_CODE_TYPES, + cardAbbreviation, + textColorOn, + type AccessKeyView, + type Card, + type CodeType, +} from '@cardy/shared'; +import { api } from './api.js'; + +/** Loads data and exposes a reload function; errors are reported via `error`. */ +export function useLoad(load: () => Promise, deps: unknown[] = []) { + const [data, setData] = useState(); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + const run = useCallback(load, deps); + const reload = useCallback(async () => { + setLoading(true); + try { + setData(await run()); + setError(undefined); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, [run]); + useEffect(() => { + void reload(); + }, [reload]); + return { data, setData, error, loading, reload }; +} + +/** Wraps an async action with a busy flag and error message. */ +export function useAction() { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const run = useCallback(async (fn: () => Promise): Promise => { + setBusy(true); + setError(undefined); + try { + return await fn(); + } catch (e) { + setError((e as Error).message); + return undefined; + } finally { + setBusy(false); + } + }, []); + return { busy, error, setError, run }; +} + +export function ErrorText({ error }: { error?: string }) { + return error ?

{error}

: null; +} + +export function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

{title}

+ +
+ {children} +
+
+ ); +} + +export function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +export function KeyQrModal({ keyView, owner, onClose }: { keyView: AccessKeyView; owner: string; onClose: () => void }) { + const [svg, setSvg] = useState(''); + useEffect(() => { + QRCode.toString(keyView.deepLink, { type: 'svg', margin: 2, errorCorrectionLevel: 'M' }).then(setSvg); + }, [keyView.deepLink]); + return ( + +

+ {owner} · created {formatDate(keyView.createdAt)} +

+
+

Scan with the Cardy app or the phone camera.

+
+ +
+ {keyView.key} + +
+
+
+ + +
+ + ); +} + +type Bwip = typeof import('bwip-js/browser'); +let bwipPromise: Promise | undefined; + +export function CodePreview({ code, codeType }: { code: string; codeType: CodeType }) { + // bwip-js is ~1 MB, load it only when a code is shown. + const [bwipjs, setBwipjs] = useState(); + useEffect(() => { + bwipPromise ??= import('bwip-js/browser').then((m) => (m as { default?: Bwip }).default ?? m); + void bwipPromise.then((b) => setBwipjs(() => b)); + }, []); + + const result = useMemo(() => { + if (!code) return { error: 'Enter a code to see the preview' }; + if (!bwipjs) return { error: 'Loading…' }; + try { + const svg = bwipjs.toSVG({ + bcid: BWIP_BCID[codeType], + text: code, + scale: 3, + ...(TWO_DIMENSIONAL_CODE_TYPES.has(codeType) ? {} : { height: 14, includetext: true, textxalign: 'center' as const }), + paddingwidth: 4, + paddingheight: 4, + backgroundcolor: 'FFFFFF', + }); + return { svg }; + } catch (e) { + return { error: String((e as Error).message ?? e).replace(/^Error:\s*/, '') }; + } + }, [bwipjs, code, codeType]); + if ('error' in result) return

{result.error}

; + return
; +} + +export function CardBadge({ card, size = 56 }: { card: Pick; size?: number }) { + const style = { width: size, height: size, background: card.color, color: textColorOn(card.color) }; + if (card.logo) { + return ( + + + + ); + } + return ( + + {cardAbbreviation(card.name)} + + ); +} + +export function FilePicker({ + label, + multiple, + disabled, + onFiles, +}: { + label: string; + multiple?: boolean; + disabled?: boolean; + onFiles: (files: File[]) => void; +}) { + const ref = useRef(null); + return ( + <> + + { + const list = [...(e.target.files ?? [])]; + e.target.value = ''; + if (list.length) onFiles(list); + }} + /> + + ); +} + +export function formatDate(iso: string): string { + return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} diff --git a/server-fe/src/main.tsx b/server-fe/src/main.tsx new file mode 100644 index 0000000..43a0be7 --- /dev/null +++ b/server-fe/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App.js'; +import './styles.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/server-fe/src/pages/CardEditor.tsx b/server-fe/src/pages/CardEditor.tsx new file mode 100644 index 0000000..23e9ff9 --- /dev/null +++ b/server-fe/src/pages/CardEditor.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from 'react'; +import { CODE_TYPES, CODE_TYPE_LABELS, cardFieldsSchema, randomCardColor, type Card, type CardFields } from '@cardy/shared'; +import { api } from '../api.js'; +import { CardBadge, CodePreview, ErrorText, FilePicker, formatDate, useAction, useLoad } from '../components.js'; +import { linkClick, navigate } from '../router.js'; + +export function CardEditorPage({ cardId }: { cardId: string }) { + const isNew = cardId === 'new'; + const loaded = useLoad(async () => (isNew ? undefined : (await api.cards()).find((c) => c.id === cardId) ?? null), [cardId]); + + if (loaded.loading && loaded.data === undefined && !isNew) return

Loading…

; + if (loaded.error) return ; + if (loaded.data === null) { + return ( +
+

Card not found.

+ + Back to cards + +
+ ); + } + return ; +} + +function Editor({ initial }: { initial?: Card }) { + const [card, setCard] = useState(initial); + const [fields, setFields] = useState( + initial + ? { name: initial.name, color: initial.color, code: initial.code, codeType: initial.codeType } + : { name: '', color: randomCardColor(), code: '', codeType: 'EAN13' }, + ); + const action = useAction(); + const [saved, setSaved] = useState(false); + + useEffect(() => setSaved(false), [fields]); + + const dirty = !card || card.name !== fields.name || card.color !== fields.color || card.code !== fields.code || card.codeType !== fields.codeType; + const set = (key: K, value: CardFields[K]) => setFields((f) => ({ ...f, [key]: value })); + + const save = async (e: React.FormEvent) => { + e.preventDefault(); + const parsed = cardFieldsSchema.safeParse(fields); + if (!parsed.success) { + action.setError(parsed.error.issues.map((i) => i.message).join('; ')); + return; + } + const result = await action.run(() => (card ? api.updateCard(card.id, parsed.data) : api.createCard(parsed.data))); + if (!result) return; + setCard(result); + setSaved(true); + if (!card) navigate(`/cards/${result.id}`, { replace: true }); + }; + + return ( +
+
+ + ← Cards + +

{card ? card.name : 'New card'}

+ {card && } +
+ + {card?.deleted && ( +
+ This card is deleted. Apps don't see it. + +
+ )} + +
+
+
+ + set('name', e.target.value)} maxLength={200} required /> +
+
+ +
+ set('color', e.target.value.toUpperCase())} /> + set('color', e.target.value)} + pattern="#[0-9a-fA-F]{6}" + size={8} + aria-label="Color hex" + /> + +
+
+
+ + +
+
+ +