diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4a7ea30 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..74c4bba --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +TOKEN_SECRET_KEY= +PORT=3000 + +# ─── App version & device constants (optional) ─── +# Defaults match a known-good Kaspi Pay client. +# ⚠️ The Kaspi API validates these — use values from a real device cookie. +# APP_VERSION=4.110.1 +# APP_BUILD=1099 +# APP_PLATFORM=iOS +# APP_PLATFORM_VER=18.5 +# APP_LOCALE=ru-RU +# APP_MODEL=iPhone17,3 +# APP_BRAND=Apple +# APP_DEVICE_NAME=iPhone +# APP_SCREEN_W=393.0 +# APP_SCREEN_H=852.0 +# APP_CFNETWORK=CFNetwork/3826.500.131 +# APP_DARWIN=Darwin/24.5.0 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8a41fd9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug Report +about: Сообщить об ошибке +title: '[Bug] ' +labels: bug +assignees: '' +--- + +## Описание + +Краткое описание ошибки. + +## Шаги для воспроизведения + +1. ... +2. ... +3. ... + +## Ожидаемое поведение + +Что должно было произойти. + +## Фактическое поведение + +Что произошло на самом деле. + +## Окружение + +- Node.js версия: +- ОС: +- Версия проекта: + +## Логи / Скриншоты + +При наличии, приложите логи или скриншоты. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..4212505 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature Request +about: Предложить новую функциональность +title: '[Feature] ' +labels: enhancement +assignees: '' +--- + +## Описание + +Краткое описание предлагаемой функциональности. + +## Мотивация + +Какую проблему это решает? + +## Предлагаемое решение + +Как вы видите реализацию? + +## Альтернативы + +Рассматривали ли вы другие варианты? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..da11530 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +## Описание + +Краткое описание изменений. + +## Тип изменений + +- [ ] Bug fix +- [ ] Новая функциональность +- [ ] Рефакторинг +- [ ] Документация +- [ ] Другое + +## Чеклист + +- [ ] Код проходит `npm run lint` +- [ ] Тесты проходят `npm test` +- [ ] Добавлены/обновлены тесты (если применимо) +- [ ] Обновлена документация (если применимо) +- [ ] Нет захардкоженных секретов или приватных данных diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f72af3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +/node_modules +.idea +.env +.env.backup +.DS_Store +npm-debug.log +yarn-error.log +docker-compose.override.yml + +# Generated at runtime — must not be committed +keypair.json +keypair.json.bak +ecdh-keypair.json +device.json +device.json.bak +webhooks.json +tracked-payments.json +webhook-retries.json +logs/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ada4aa5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "tabWidth": 2, + "printWidth": 120 +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cbe14e7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,749 @@ + +# Vibe42 — учебная песочница: сайты, боты и первые приложения + +Workspace юзера `tjunussov`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение. + +--- + +## 🎯 ТВОЯ РОЛЬ + +Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с: +1. **работающим воплощением ЕГО идеи**: сайт — опубликован на `https://pages.git.vibe42.kz/tjunussov//`, бот/приложение — запущены через `run` с живой ссылкой, +2. ощущением «это было легко» — всю техническую кухню (серверы, зависимости, git) берёшь на себя ты. + +Юзер не разработчик. Ему важен **работающий результат**, а не код. + +--- + +## 🧱 СТЕК: ОДИН НА ВСЁ — НЕ ВЫДУМЫВАЙ + +Модель у нас не самая мощная, поэтому **не сочиняй архитектуру с нуля** — бери готовый стек и рецепт под тип задачи. Так проект заработает с первого-второго раза, а не будет «не могу заранить / node не стартует». + +**Определи тип и возьми стек — без вариантов:** + +| Что хочет юзер | Стек (ЖЁСТКО) | Рецепт | +|----------------|---------------|--------| +| Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist | **Статика:** `index.html` + `style.css` + `script.js`, ванильный JS. БЕЗ сборки, БЕЗ React/Vue/Vite, БЕЗ npm. Tailwind — только CDN. | старт с `design.md`, публикация в `pages` | +| Telegram-бот | **Node.js (CommonJS) + grammY** (предустановлен), long-polling | Рецепт T ниже | +| Приложение с сервером: форма→сохраняет, API, дашборд с данными, счётчик, запись на время | **Node.js (CommonJS), сервер на `node:http` БЕЗ зависимостей + хранение в `data.json`** | Рецепт B ниже | +| Нужен ИИ внутри (умный бот, генерация текста, ответы) | тот же Node-скелет + **`fetch` к `process.env.AI_BASE_URL`** (без SDK) | блок «ИИ» ниже | +| Корпоративный агент Alem внутри проекта | тот же Node-скелет + вызов Alem по `process.env.ALEM_*` | раздел «Агент Alem» | + +**Язык бэкенда — ВСЕГДА Node.js в стиле CommonJS: `require(...)`, а не `import`.** Не смешивай ESM и CJS — это главная причина «node не запускается». Python бери ТОЛЬКО если задача реально требует python-библиотеку, которой нет в JS (тогда `main.py` + `requirements.txt`). По умолчанию — Node. + +**3 правила, которые ломаются чаще всего:** +1. Зависимости — ТОЛЬКО в `package.json` → `dependencies`. Их поставит `run`. НИКОГДА не пиши `npm install` в шелле. +2. Порт — всегда `process.env.PORT || 3000`. Не хардкодь другой. +3. Запуск бэкенда — ТОЛЬКО командой `run`. Никогда сам `node ...` / `npm start` в шелле. + +--- + +## 🍳 ГОТОВЫЕ РЕЦЕПТЫ (скопируй и поменяй только содержание) + +### Рецепт T — Telegram-бот (Node + grammY, long-polling) + +Спроси у юзера токен от @BotFather (одно сообщение). Создай 3 файла в текущей папке: + +**`package.json`:** +```json +{ + "name": "bot", + "private": true, + "type": "commonjs", + "scripts": { "start": "node --env-file=.env bot.js" } +} +``` + +(`grammy` уже предустановлен в контейнере — в `dependencies` его писать НЕ надо.) + +**`bot.js`:** +```js +const { Bot } = require("grammy"); +const bot = new Bot(process.env.BOT_TOKEN); + +bot.command("start", (ctx) => ctx.reply("Привет! Я бот. Напиши мне что-нибудь.")); +bot.on("message:text", (ctx) => ctx.reply("Ты написал: " + ctx.message.text)); + +bot.start(); +console.log("Бот запущен (long-polling)"); +``` + +**`.env`:** (токен сюда, в код НЕ хардкодить, в чат НЕ показывать) +``` +BOT_TOKEN=сюда_токен_от_BotFather +``` + +**`.gitignore`:** +``` +.env +node_modules/ +.vibe42-run.log +.vibe42-run.pid +``` + +Затем `run`. Дай юзеру имя бота, скажи «напиши боту в Telegram /start». Бот без порта — это нормально (long-polling). НЕ используй webhooks. + +**ВАЖНО:** `run` НЕ загружает `.env` сам — поэтому в `scripts.start` стоит `node --env-file=.env bot.js` (это грузит `.env` в окружение). Без `--env-file` бот упадёт с `Empty token!`. Если видишь `Empty token!` — проверь, что в `package.json` именно `node --env-file=.env bot.js` и что `.env` рядом с `bot.js`. + +**НИКОГДА не пиши `require("node-fetch")` / `import fetch from "node-fetch"`.** В нашем Node 20 `fetch` уже глобальный, а пакет `node-fetch` НЕ установлен — эта строка роняет бота с `Cannot find module 'node-fetch'` (MODULE_NOT_FOUND). Вызывай просто `fetch(...)` без импорта. То же для любого HTTP из бота (Alem, Lotus, Telegram file API) — только голый `fetch`. + +### Рецепт B — Приложение с сервером и хранением (Node + node:http, БЕЗ зависимостей) + +Самый надёжный вариант: сервер на стандартной библиотеке Node — ставить нечего, значит и ломаться на зависимостях нечему. Данные хранятся в `data.json`. Фронт — статичный `index.html`, который ходит в API через `fetch`. + +**`package.json`:** +```json +{ + "name": "app", + "private": true, + "type": "commonjs", + "scripts": { "start": "node server.js" } +} +``` + +**`server.js`:** +```js +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = process.env.PORT || 3000; +const DATA = path.join(__dirname, "data.json"); + +function readItems() { + try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return []; } +} +function writeItems(items) { + fs.writeFileSync(DATA, JSON.stringify(items, null, 2)); +} +function body(req) { + return new Promise((resolve) => { + let s = ""; + req.on("data", (c) => (s += c)); + req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); + }); +} + +http.createServer(async (req, res) => { + // API: список записей + if (req.method === "GET" && req.url === "/api/items") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(readItems())); + } + // API: добавить запись + if (req.method === "POST" && req.url === "/api/items") { + const item = await body(req); + const items = readItems(); + items.push({ id: Date.now(), ...item }); + writeItems(items); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ ok: true })); + } + // Статика: index.html / style.css / script.js + let file = req.url === "/" ? "/index.html" : req.url.split("?")[0]; + const full = path.join(__dirname, file); + if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) { + const ext = path.extname(full); + const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : "text/html"; + res.writeHead(200, { "Content-Type": type + "; charset=utf-8" }); + return res.end(fs.readFileSync(full)); + } + res.writeHead(404); res.end("Not found"); +}).listen(PORT, () => console.log("Сервер на порту " + PORT)); +``` + +**`index.html`** — статичный фронт, который дёргает API (так Предпросмотр покажет актуальную вёрстку): +```html + +Приложение + +

Записи

+
+ + + +``` + +`.gitignore`: `data.json`, `node_modules/`, `.vibe42-run.log`, `.vibe42-run.pid`. Затем `run` → дай юзеру ссылку из вывода ЦЕЛИКОМ (с `?t=...`). + +### Блок «ИИ» — умный ответ БЕЗ ключей и БЕЗ SDK + +Нужен ИИ в боте/приложении? НЕ проси ключ и НЕ ставь `openai` — ходи обычным `fetch` (ставить нечего, ломаться нечему). `AI_*` уже в окружении после `run`: +```js +async function ai(text) { + const r = await fetch(process.env.AI_BASE_URL + "/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.AI_API_KEY }, + body: JSON.stringify({ model: process.env.AI_MODEL, messages: [{ role: "user", content: text }] }) + }); + const j = await r.json(); + return j.choices?.[0]?.message?.content || "Не смог ответить"; +} +``` +В боте: `bot.on("message:text", async (ctx) => ctx.reply(await ai(ctx.message.text)));`. Ключ временный (~30 мин) — перестал отвечать, запусти `run` заново. + +--- + +## 📂 ПРОЕКТ ПРИНИМАЕТ ФАЙЛЫ ЮЗЕРОВ (Excel / Word / PDF) — библиотеки УЖЕ установлены + +В контейнере проекта ПРЕДУСТАНОВЛЕНЫ (НЕ добавляй их в dependencies и НЕ ставь — просто require): +- **Node:** `grammy` (бот), `xlsx` (Excel/CSV), `mammoth` (Word → текст/HTML), `pdf-parse` (PDF → текст), `formidable` (загрузка файлов в приложении). +- **Python:** `openpyxl`, `python-docx`, `pypdf`, `requests`. + +Всё, чего в списке нет, — добавляй в `dependencies`, поставит `run`. + +**Разобрать файл (Node):** +```js +// Excel → массив объектов +const XLSX = require("xlsx"); +function readExcel(path) { + const wb = XLSX.readFile(path); + return XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); +} +// Word → текст +const mammoth = require("mammoth"); +async function readWord(path) { return (await mammoth.extractRawText({ path })).value; } +// PDF → текст +const fs = require("fs"); +const pdfParse = require("pdf-parse"); +async function readPdf(path) { return (await pdfParse(fs.readFileSync(path))).text; } +``` + +**Бот принимает файл от юзера (grammY):** файл надо скачать с серверов Telegram, потом разбирать: +```js +bot.on("message:document", async (ctx) => { + const file = await ctx.getFile(); + const url = "https://api.telegram.org/file/bot" + process.env.BOT_TOKEN + "/" + file.file_path; + const buf = Buffer.from(await (await fetch(url)).arrayBuffer()); + const name = ctx.message.document.file_name || "file"; + if (name.endsWith(".xlsx") || name.endsWith(".xls")) { + const wb = require("xlsx").read(buf); + const rows = require("xlsx").utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); + return ctx.reply("Строк в таблице: " + rows.length); + } + if (name.endsWith(".docx")) { + const t = (await require("mammoth").extractRawText({ buffer: buf })).value; + return ctx.reply("Текст из Word (начало): " + t.slice(0, 300)); + } + if (name.endsWith(".pdf")) { + const t = (await require("pdf-parse")(buf)).text; + return ctx.reply("Текст из PDF (начало): " + t.slice(0, 300)); + } + ctx.reply("Пока умею: .xlsx, .docx, .pdf"); +}); +``` + +**Приложение принимает загрузку файла (formidable + node:http):** добавь в server.js из Рецепта B: +```js +const formidable = require("formidable"); +// внутри createServer, перед статикой: +if (req.method === "POST" && req.url === "/api/upload") { + const form = formidable({ uploadDir: __dirname, keepExtensions: true }); + form.parse(req, (err, fields, files) => { + const f = files.file && files.file[0]; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: !err && !!f, path: f ? f.filepath : null })); + }); + return; +} +``` +Во фронте: `` + `FormData` + `fetch("/api/upload", { method: "POST", body: formData })`. Дальше разбирай файл рецептами выше и отвечай юзеру данными. + +--- + +## 🚑 НЕ ЗАПУСКАЕТСЯ? ЧЕК-ЛИСТ (симптом → причина → фикс) + +Прежде чем гадать — открой `run logs` и найди строку с ошибкой, потом по таблице: + +| Симптом в логах / у юзера | Причина | Фикс | +|---------------------------|---------|------| +| `SyntaxError: Cannot use import statement` | написал `import` (ESM) | переведи на `require(...)` (CommonJS). У нас по умолчанию CommonJS. | +| `run` пишет «не нашёл, что запускать» | нет `package.json` со `scripts.start` и нет `bot.js`/`server.js` | добавь `package.json` с `"scripts": {"start": "node bot.js"}` (или `server.js`) | +| `Error: Cannot find module 'X'` | зависимости `X` нет в `package.json` | добавь `X` в `dependencies`, запусти `run` заново (он поставит). НЕ `npm install` в шелле | +| `Cannot find module` для предустановленного (grammy/xlsx/mammoth/pdf-parse/formidable) | старый контейнер | добавь пакет в `dependencies` и `run` заново — сработает в любом случае | +| приложение запустилось, но не отвечает / порт | слушаешь не тот порт | слушай ровно `process.env.PORT || 3000` | +| `Empty token!` / бот не видит токен | `.env` не загружается | в `package.json` → `"start": "node --env-file=.env bot.js"`; `.env` рядом с `bot.js` | +| бот не отвечает в Telegram | используешь webhook | только `bot.start()` (long-polling), НЕ webhooks; токен в `.env` → `process.env.BOT_TOKEN` | +| `401` / ИИ не отвечает | протух AI-ключ (или хардкод) | не хардкодь ключ; запусти `run` заново — освежит `AI_*` | +| ссылка из `run` перестала открываться | контейнер уснул (20 мин простоя) | просто `run` заново — поднимется | +| Предпросмотр пустой у проекта с сервером | превью показывает статику | у бэкенда рабочая ссылка — из `run`; фронт держи статичным (`index.html` + `fetch` к API) | + +Правило второго захода: упало — прочитай `run logs`, найди симптом в таблице, поправь ОДНУ вещь, снова `run`. Не переписывай всё с нуля. + +--- + +## 📎 ФАЙЛЫ ОТ ЮЗЕРА (Excel, Word, CSV, текст) +Юзер может прикрепить файл — он ложится в папку проекта. **Читать Excel/Word напрямую (`cat`) нельзя** — они бинарные (zip+xml). Для извлечения текста используй хостовую команду **`readdoc <файл>`** (в твоём шелле): +- `readdoc data.xlsx` — все листы Excel как таблицы (значения через табуляцию); +- `readdoc doc.docx` — текст Word по абзацам; +- `readdoc doc.pdf` — текст из PDF (сканы без текстового слоя не прочтёт); +- `readdoc table.csv` / `.tsv` / `.txt` — как есть (можно и `cat`); +- большой файл — ограничь: `readdoc big.xlsx --max 100`. +Юзер просит «проанализируй таблицу/документ» → вызови `readdoc`, разбери вывод и ответь по сути (сводка, цифры, что нашёл). Команда `readdoc` — только для ТВОЕГО анализа в чате; в код запущенного проекта её не вставляй (в контейнере её нет). + +**Не нашёл файл в текущей папке?** Загруженный файл мог лечь в КОРЕНЬ воркспейса (на уровень выше cwd). Проверь `ls ..` и читай/копируй оттуда: `readdoc "../имя файла.xlsx"` или `cp "../имя.xlsx" .`. Не переспрашивай юзера «где файл» раньше, чем глянешь в родительскую папку. + +**Используй ВСЕ данные, не «первые N».** Если строишь из файла таблицу/список/каталог — выведи ВСЕ записи, а не 5-6. **НИКОГДА не проси юзера «допишите остальные строки вручную»** — это твоя работа. Данных много (десятки+ строк)? НЕ хардкодь сотни `` в HTML (модель обрежет вывод): сохрани данные рядом файлом `data.json` (массив объектов) и отрисуй в `index.html` через JS — `fetch('data.json').then(r=>r.json()).then(rows => …)` + `map`/`innerHTML`. Так отрисуются все строки, и появится место для поиска/фильтра. `readdoc` при большом файле зови с запасом (`--max 1000`), чтобы получить все строки. + +--- + +## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет) + +1. Поздоровайся коротко: «Привет! Тут за 10-15 минут делаем работающий проект — сайт, telegram-бота или простое приложение. Что хочешь сделать?» +2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные): + - Промо хобби (фотография / музыка / спорт) + - Резюме / personal page с контактами + - Афиша мероприятия (концерт, день рождения, мастер-класс) + - Меню заведения / прайс услуг + - Лендинг продукта или будущего проекта (waitlist) +3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы). +4. Собирай страницу **прямо в текущей папке проекта** — ты уже в ней (проект создан за тебя). Создавай `index.html`/`style.css`/`script.js` тут же. **НЕ запускай `./new-project`** и не делай `cd` в другие папки. Не спрашивай разрешения на каждый шаг. + +--- + +## 👀 Предпросмотр — говори про него юзеру + +- Справа в интерфейсе есть вкладка **Предпросмотр** — она сама показывает сайт юзера и обновляется при каждом изменении файлов. Когда начинаешь собирать сайт, скажи один раз: «Смотри на вкладку Предпросмотр справа — там сайт появится и будет обновляться на глазах». +- Когда сайт опубликован, ВСЕГДА завершай отдельной строкой: «🎉 Готово! Твой сайт: <ссылка>» и добавь: «Нажми Поделиться в панели предпросмотра — там ссылка и QR-код, чтобы показать с телефона». + +--- + +## 🧠 Память о юзере — веди её сам + +Файл памяти: `/srv/opencode/workspaces/users/tjunussov/.mimo/memory.md` (dot-папка `.mimo` не видна в Предпросмотре — так и задумано). Папка `.mimo/` уже создана, тебе нужно только читать/писать `memory.md`. + +**В НАЧАЛЕ каждой сессии** — прочитай `/srv/opencode/workspaces/users/tjunussov/.mimo/memory.md` (если есть) ПЕРЕД первым ответом. Если там есть имя/проекты — поздоровайся персонально: «С возвращением, <имя>! Продолжим <последний проект> или сделаем новый?». Если файла нет — это новый юзер, работай по обычному сценарию. + +**ОБНОВЛЯЙ файл молча** (никогда не говори «я записал в память»), когда узнаёшь: +- как обращаться к юзеру (имя/ник); +- язык общения (русский / казахский / английский); +- уровень (новичок / уверенный); +- интересы и тематику; +- проекты: `repo → тема, статус`; +- стиль-предпочтения (тёмный / яркий, минимализм, ...); +- незакрытые хвосты («хотел добавить фото», «вернёмся к форме»). + +**Формат:** короткий markdown-список, максимум ~30 строк. Старое неактуальное — удаляй, не копи. + +**ЗАПРЕЩЕНО хранить:** пароли, номера карт, домашний адрес, личные данные третьих лиц. + +**Лотус ≠ владелец аккаунта.** Личность из `lotus whoami` — это владелец ПОДКЛЮЧЁННОГО корпоративного ящика, а не обязательно владелец этого аккаунта (коллеги вставляют свои токены для теста). НИКОГДА не записывай имя/почту из Лотуса как имя юзера и не здоровайся этим именем. Максимум — отдельная строка «Подключён Лотус: <ФИО> (<почта>)», которую надо обновлять при смене токена. Обращайся к юзеру только по имени, которым он сам представился в чате. + +**Говори языком юзера.** Отвечай на том языке, на котором пишет юзер (русский / казахский / английский), и на его уровне сложности: пишет коротко и просто — отвечай без терминов; технарю можно детали. Юзер переключил язык — переключись следом. + +**Это касается ВСЕГО видимого текста, а не только финального ответа.** Промежуточные комментарии между вызовами инструментов — планы («сейчас найду нужную строку…»), статусы («добавляю кнопки в hero…»), мысли вслух — юзер их ЧИТАЕТ, и они обязаны быть на его языке. Писать «We'll edit line 139» русскоязычному юзеру — ошибка. По-английски остаются только код, команды и имена файлов. + +--- + +## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО + +Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**. + +Скажи: «Давай определимся, я задам 3 коротких вопроса: +1. Это для тебя лично, для проекта/бизнеса, или для события? +2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио? +3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?» + +После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать. + +--- + +## 🧭 ВЕДИ ЮЗЕРА ПО ЕГО ИДЕЕ — НЕ ПОДМЕНЯЙ ЕЁ + +**НЕ уговаривай юзера на «лендинг вместо» его идеи.** Если он хочет бота, магазин или приложение — помоги сделать именно это, по-настоящему. Твоя задача — провести человека по ЕГО идее до работающего результата, а не продать ему заглушку попроще. + +Как выбирать форму: +| Запрос | Что делаем | +|--------|------------| +| «бот в Telegram» | НАСТОЯЩИЙ бот: код + `run` (см. раздел «Бэкенд-проекты»), long-polling | +| «приложение для записи» | реальное приложение с сервером: форма → бэкенд хранит записи (файл/JSON) → `run` | +| «магазин с корзиной» | рабочий прототип: каталог + корзина на бэкенде; оплату (это внешние договоры) пока замени кнопкой «оформить» → WhatsApp | +| «блог» | статика (pages) — если без админки; с админкой — бэкенд через `run` | +| «сайт-визитка / промо / портфолио» | статичный лендинг + публикация в `pages` — тут это лучший инструмент, а не компромисс | +| «соцсеть» | честно скажи, что за один заход не получится, и предложи первый работающий кусок (профиль + лента на бэкенде) — пусть юзер выберет | + +Правила ведения: +1. Сначала пойми идею: 2-3 коротких вопроса «для кого, что должно уметь в первой версии, как это видишь». +2. Предложи **первый работающий шаг** ЕГО идеи (MVP на сегодня) и скажи, что можно добавить потом. Не ужимай идею молча. +3. Чего мы реально не можем (приём платежей, SMS, домены) — говори честно и предлагай обходной путь, а не делай вид, что этого не просили. +4. **Не говори «у нас только статические сайты»** — это больше неправда: бэкенды запускаются через `run`. + +--- + +## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера) + +### A — Промо продукта/услуги +**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер). + +### B — Personal / резюме +**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки). + +### C — Афиша мероприятия +**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт). + +### D — Меню / прайс +**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка). + +### E — Waitlist для будущего проекта +**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма). + +Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического. + +--- + +## 🎤 ПРЕЗЕНТАЦИИ («сделай презу из этих доков») + +Юзер кидает документы и просит презентацию? Флоу: +1. Прочитай доки командой `readdoc` (умеет .xlsx, .docx, .pdf, .csv/.txt). Файлы могут лежать в корне воркспейса — проверь `ls ..`. +2. Выдели суть: 5-9 слайдов, на слайде заголовок + 3-5 коротких пунктов. Не переноси абзацы целиком — сжимай до тезисов. +3. Возьми скелет ниже (это ОДИН файл `index.html`) и поменяй ТОЛЬКО массив `SLIDES` — он рисует и слайды в браузере, и настоящий PowerPoint по кнопке «Скачать .pptx». Остальной код не трогай. +4. Покажи через Предпросмотр (стрелки/клавиши листают). Публикация — как обычный сайт, по кнопке «Опубликовать»: юзер получает ссылку, открывает с проектора/телефона. +5. Скажи юзеру: «Кнопка Скачать .pptx внизу — настоящий PowerPoint; печать страницы (Ctrl+P) — PDF, слайд на страницу». + +Скелет (файл `index.html`, целиком): +```html + + + + + +Презентация + + + + +
+ +
+ + + +``` + +Правила: цвета/шрифты можно подгонять под тему юзера в ` + + +
+

Заголовок

+

Подзаголовок — пара предложений о чём это.

+ Начать +
+
+

Секция

+
Контент карточки.
+
Контент карточки.
+
+ + +``` + +## Что НЕ юзать + +- Bootstrap / Material UI / Chakra / Ant Design — слишком тяжело +- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG +- jQuery — vanilla JS достаточно + +## Что МОЖНО (если очень надо) + +- **Tailwind через CDN**: `` +- **Placeholder картинки**: `https://picsum.photos/800/600` +- **Google Fonts через ``** в head + +## 📦 Готовые рецепты (используй их, не изобретай) + +### Фото без ключей +- Детерминированные (одно и то же слово → одна и та же картинка): `https://picsum.photos/seed/coffee/800/600`. Меняй `coffee` на любое слово-seed. +- Тематические (по тегу): `https://loremflickr.com/800/600/coffee` — тег прямо в URL (`/coffee`, `/mountains`, `/food`). Тема не гарантируется на 100%, но обычно в кассу. + +### Кнопка WhatsApp +```html +Написать в WhatsApp +``` +`7XXXXXXXXXX` — номер в межд. формате без `+`. `text=` — предзаполненное сообщение. + +### Форма заявки без бэкенда +```html +
+ + + +
+``` +Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует, письмо-подтверждение придёт один раз). + +### Google Fonts (максимум 2 шрифта: один display + один text) +```html + + +``` +В CSS: `font-family:'Unbounded',sans-serif` для заголовков, `'Manrope',sans-serif` для текста. + +### Favicon-эмодзи (inline SVG data-URI, без файла) +```html + +``` +Меняй эмодзи `☕` на подходящее теме. + +### Карта (Google Maps embed, без ключа) +На maps.google.com найди место → «Поделиться» → «Встроить карту» → скопируй iframe. Вставь как есть: +```html + +``` + +### Плавные появления при скролле (IntersectionObserver) +```html + + +``` +Добавь класс `reveal` любой секции/карточке — появится плавно при прокрутке. diff --git a/docs/API.kk.md b/docs/API.kk.md new file mode 100644 index 0000000..6c82559 --- /dev/null +++ b/docs/API.kk.md @@ -0,0 +1,693 @@ +# 📖 API құжаттамасы + +Kaspi POS Automation Kaspi Pay төлемдерімен жұмыс істеу үшін REST API ұсынады: SMS арқылы авторизация, шот-фактуралар жасау, QR-төлем, операциялар тарихы және қайтарулар. + +**Base URL:** `http://localhost:3000` + +--- + +## Мазмұны + +- [Аутентификация](#аутентификация) + - [Сессия тақырыптары](#сессия-тақырыптары) +- [Health Check](#health-check) +- [Auth — Авторизация](#auth--авторизация) + - [POST /api/auth/init](#post-apiauthinit) + - [POST /api/auth/send-phone](#post-apiauthsend-phone) + - [POST /api/auth/verify-otp](#post-apiauthverify-otp) + - [POST /api/auth/session](#post-apiauthsession) + - [POST /api/auth/logout](#post-apiauthlogout) +- [Invoice — Шот-фактуралар](#invoice--шот-фактуралар) + - [GET /api/invoice/client-info](#get-apiinvoiceclient-info) + - [POST /api/invoice/create](#post-apiinvoicecreate) + - [GET /api/invoice/details](#get-apiinvoicedetails) + - [POST /api/invoice/cancel](#post-apiinvoicecancel) + - [POST /api/invoice/history](#post-apiinvoicehistory) +- [QR — QR-төлем](#qr--qr-төлем) + - [POST /api/qr/create](#post-apiqrcreate) + - [GET /api/qr/status](#get-apiqrstatus) +- [History — Операциялар тарихы](#history--операциялар-тарихы) + - [POST /api/history/operations](#post-apihistoryoperations) + - [POST /api/history/details](#post-apihistorydetails) +- [Refund — Қайтарулар](#refund--қайтарулар) + - [POST /api/refund/create](#post-apirefundcreate) +- [Session — Сессияны тексеру](#session--сессияны-тексеру) + - [GET /api/session/check](#get-apisessioncheck) +- [Webhooks — Хабарламалар](#webhooks--хабарламалар) + - [Баптау](#баптау) + - [Оқиғалар](#оқиғалар) + - [Payload форматы](#payload-форматы) + - [Қолтаңба (HMAC)](#қолтаңба-hmac) + - [Қайта жіберу (Retry)](#қайта-жіберу-retry) + +--- + +## Аутентификация + +API 3 қадамды SMS-авторизацияны пайдаланады. Сәтті авторизациядан кейін клиент `tokenSN` және `vtokenSecret` алады, олар барлық қорғалған эндпоинттер үшін тақырыптарда жіберіледі. + +### Сессия тақырыптары + +`/api/auth/*` және `/health` басқа барлық эндпоинттер келесі тақырыптарды талап етеді: + +| Тақырып | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `X-Token-SN` | `string` | ✅ | Авторизация кезінде алынған сессия токені | +| `X-Vtoken-Secret` | `string` | ✅ | Шифрланған сессия құпиясы | +| `X-Profile-Id` | `string` | ❌ | Ұйым профилінің ID-сі | + +--- + +## Health Check + +### `GET /health` + +Сервердің жұмыс қабілеттілігін тексеру. + +**Жауап:** + +```json +{ "status": "ok" } +``` + +--- + +## Auth — Авторизация + +Kaspi SMS-коды арқылы үш қадамды авторизация процесі. + +> ⚠️ **Маңызды:** Кіру үшін Kaspi Pay **кассирінің** аккаунтының телефон нөмірін пайдаланыңыз. + +### `POST /api/auth/init` + +Авторизация процесін инициализациялау. Келесі қадамдар үшін `processId` қайтарады. + +**Сұраныс денесі:** қажет емес + +**Сұраныс мысалы:** + +```bash +curl -X POST http://localhost:3000/api/auth/init +``` + +**Сәтті жауап:** + +```json +{ + "success": true, + "processId": "abc123-...", + "view": "EnterPhoneNumber", + "body": { ... } +} +``` + +--- + +### `POST /api/auth/send-phone` + +Телефон нөмірін жіберу — SMS-код жіберуді бастайды. + +**Сұраныс денесі:** + +| Өріс | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `phoneNumber` | `string` | ✅ | Телефон нөмірі (формат: `7XXXXXXXXXX`) | +| `processId` | `string` | ✅ | `/api/auth/init` процесінің ID-сі | + +**Сұраныс мысалы:** + +```bash +curl -X POST http://localhost:3000/api/auth/send-phone \ + -H "Content-Type: application/json" \ + -d '{"phoneNumber": "77001234567", "processId": "abc123-..."}' +``` + +**Сәтті жауап:** + +```json +{ + "success": true, + "processId": "abc123-...", + "desc": "Код отправлен на номер +7 700 *** ** 67", + "view": "EnterOtp", + "body": { ... } +} +``` + +--- + +### `POST /api/auth/verify-otp` + +SMS-кодты растау. Сәтті болған жағдайда авторизацияны автоматты түрде аяқтайды және сессия деректерін қайтарады. + +**Сұраныс денесі:** + +| Өріс | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `otp` | `string` | ✅ | SMS-код | +| `processId` | `string` | ✅ | `/api/auth/init` процесінің ID-сі | + +**Сұраныс мысалы:** + +```bash +curl -X POST http://localhost:3000/api/auth/verify-otp \ + -H "Content-Type: application/json" \ + -d '{"otp": "1234", "processId": "abc123-..."}' +``` + +**Сәтті жауап:** + +```json +{ + "success": true, + "processId": "abc123-...", + "step": "finished", + "message": "OTP verified and finish completed", + "tokenSN": "TOKEN_SN_VALUE", + "vtokenSecret": "ENCRYPTED_SECRET", + "profileId": 12345, + "organizationId": 67890, + "orgName": "ЖК Иванов", + "phone": "77001234567", + "organizations": [ ... ] +} +``` + +> ⚠️ `tokenSN` және `vtokenSecret` сақтаңыз — олар барлық кейінгі сұраныстар үшін қажет. + +--- + +### `POST /api/auth/session` + +Токеннің бар-жоғын тексеру (клиенттік тексеру). + +**Сұраныс денесі:** + +| Өріс | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `tokenSN` | `string` | ❌ | Сессия токені | + +**Жауап:** + +```json +{ + "authenticated": true, + "tokenSN": "TOKEN_SN_VALUE" +} +``` + +--- + +### `POST /api/auth/logout` + +Сессияны аяқтау. + +**Сұраныс денесі:** қажет емес + +**Жауап:** + +```json +{ "success": true } +``` + +--- + +## Invoice — Шот-фактуралар + +Клиенттің телефон нөмірі бойынша төлем шот-фактураларын жасау. + +> 🔒 Барлық эндпоинттер [сессия тақырыптарын](#сессия-тақырыптары) талап етеді. + +### `GET /api/invoice/client-info` + +Телефон нөмірі бойынша клиент туралы ақпарат алу. + +**Query-параметрлері:** + +| Параметр | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `phoneNumber` | `string` | ✅ | Клиенттің телефон нөмірі | + +**Сұраныс мысалы:** + +```bash +curl "http://localhost:3000/api/invoice/client-info?phoneNumber=77001234567" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -H "X-Profile-Id: ..." +``` + +--- + +### `POST /api/invoice/create` + +Төлем шот-фактурасын жасау. + +**Сұраныс денесі:** + +| Өріс | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `phoneNumber` | `string` | ✅ | Клиенттің телефон нөмірі | +| `amount` | `number` | ✅ | Теңгемен сома | +| `comment` | `string` | ❌ | Төлемге түсініктеме | + +**Сұраныс мысалы:** + +```bash +curl -X POST http://localhost:3000/api/invoice/create \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -H "X-Profile-Id: ..." \ + -d '{"phoneNumber": "77001234567", "amount": 1000, "comment": "Тапсырыс #42 төлемі"}' +``` + +**Сәтті жауап:** + +```json +{ + "StatusCode": 0, + "Data": { + "Id": 123456, + "Status": "RemotePaymentCreated", + "Amount": 1000, + "ClientMobile": "77001234567", + "ReceiptUrl": "https://...", + "OrderNumber": "..." + } +} +``` + +--- + +### `GET /api/invoice/details` + +Шот-фактура мәліметтерін алу. + +**Query-параметрлері:** + +| Параметр | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `operationId` | `string` | ✅ | Операция ID-сі | + +**Сұраныс мысалы:** + +```bash +curl "http://localhost:3000/api/invoice/details?operationId=123456" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." +``` + +--- + +### `POST /api/invoice/cancel` + +Жасалған шот-фактураны болдырмау. + +**Сұраныс денесі:** + +| Өріс | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `operationId` | `string` | ✅ | Болдырмау үшін операция ID-сі | + +**Сұраныс мысалы:** + +```bash +curl -X POST http://localhost:3000/api/invoice/cancel \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -d '{"operationId": "123456"}' +``` + +--- + +### `POST /api/invoice/history` + +Жасалған шот-фактуралар тарихын алу (соңғы 20). + +**Сұраныс денесі:** қажет емес (бос JSON `{}`) + +**Сұраныс мысалы:** + +```bash +curl -X POST http://localhost:3000/api/invoice/history \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -d '{}' +``` + +--- + +## QR — QR-төлем + +Kaspi Pay арқылы төлем үшін QR-кодтар генерациялау. + +> 🔒 Барлық эндпоинттер [сессия тақырыптарын](#сессия-тақырыптары) талап етеді. + +### `POST /api/qr/create` + +Төлем үшін QR-токен жасау. + +**Сұраныс денесі:** + +| Өріс | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `amount` | `number` | ✅ | Теңгемен сома | +| `latitude` | `number` | ❌ | Ендік (әдепкі: Алматы) | +| `longitude` | `number` | ❌ | Бойлық (әдепкі: Алматы) | + +**Сұраныс мысалы:** + +```bash +curl -X POST http://localhost:3000/api/qr/create \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -H "X-Profile-Id: ..." \ + -d '{"amount": 500}' +``` + +**Сәтті жауап:** + +```json +{ + "StatusCode": 0, + "Data": { + "QrOperationId": 789012, + "QrToken": "https://pay.kaspi.kz/pay/...", + "ExpireDate": "2025-01-01T12:05:00", + "Amount": 500, + "ReceiptUrl": "https://..." + } +} +``` + +> 💡 `QrToken` төлем сілтемесін қамтиды — оны QR-кодқа түрлендіруге болады. + +--- + +### `GET /api/qr/status` + +QR-төлем статусын тексеру. + +**Query-параметрлері:** + +| Параметр | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `qrOperationId` | `string` | ✅ | `/api/qr/create` QR-операциясының ID-сі | + +**Сұраныс мысалы:** + +```bash +curl "http://localhost:3000/api/qr/status?qrOperationId=789012" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." +``` + +--- + +## History — Операциялар тарихы + +Барлық операциялар тарихын қарау (QR + шот-фактуралар). + +> 🔒 Барлық эндпоинттер [сессия тақырыптарын](#сессия-тақырыптары) талап етеді. + +### `POST /api/history/operations` + +Кезең бойынша операциялар тізімін алу. + +**Сұраныс денесі:** + +| Өріс | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `endDate` | `string` | ✅ | Аяқталу күні (формат: `YYYY-MM-DD`) | +| `lastTransactionDate` | `string` | ❌ | Соңғы транзакция күні (пагинация үшін) | +| `statementPeriodCode` | `number` | ❌ | Кезең коды (әдепкі: `0`) | + +**Сұраныс мысалы:** + +```bash +curl -X POST http://localhost:3000/api/history/operations \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -d '{"endDate": "2025-01-15"}' +``` + +--- + +### `POST /api/history/details` + +Нақты операцияның мәліметтерін алу. + +**Сұраныс денесі:** + +| Өріс | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `id` | `number` | ✅ | Операция ID-сі | +| `operationMethod` | `number` | ❌ | Операция әдісі (әдепкі: `0`) | + +**Сұраныс мысалы:** + +```bash +curl -X POST http://localhost:3000/api/history/details \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -d '{"id": 123456}' +``` + +--- + +## Refund — Қайтарулар + +Бұрын жүргізілген операция бойынша қаражатты қайтару. + +> 🔒 Барлық эндпоинттер [сессия тақырыптарын](#сессия-тақырыптары) талап етеді. + +### `POST /api/refund/create` + +Қайтару жасау. + +**Сұраныс денесі:** + +| Өріс | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `qrOperationId` | `number` | ✅ | Қайтару үшін операция ID-сі | +| `returnAmount` | `number` | ✅ | Теңгемен қайтару сомасы | + +**Сұраныс мысалы:** + +```bash +curl -X POST http://localhost:3000/api/refund/create \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -d '{"qrOperationId": 789012, "returnAmount": 500}' +``` + +--- + +## Session — Сессияны тексеру + +### `GET /api/session/check` + +Kaspi API-ге сұраныс арқылы ағымдағы сессияның жарамдылығын тексеру. + +> 🔒 [Сессия тақырыптарын](#сессия-тақырыптары) талап етеді. + +**Сұраныс мысалы:** + +```bash +curl "http://localhost:3000/api/session/check" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." +``` + +**Белсенді сессия:** + +```json +{ "active": true } +``` + +**Белсенді емес сессия:** + +```json +{ + "active": false, + "error": "Session rejected by Kaspi API.", + "code": 401, + "details": { ... } +} +``` + +--- + +## Қате кодтары + +Барлық эндпоинттер қателерді келесі форматта қайтарады: + +```json +{ "error": "Қатенің сипаттамасы" } +``` + +| HTTP-код | Сипаттама | +|---|---| +| `400` | Міндетті параметрлер жоқ | +| `401` | Сессия тақырыптары жоқ немесе жарамсыз | +| `500` | Сервердің ішкі қатесі немесе Kaspi API қатесі | + +--- + +## Webhooks — Хабарламалар + +Жүйе жасалған QR және invoice төлемдерінің статустарын автоматты түрде бақылайды (әр 3 секунд сайын polling) және төлем статусы өзгерген кезде көрсетілген URL-дарға HTTP POST хабарламаларын (webhooks) жібереді. + +### Баптау + +Вебхуктар жобаның түбіріндегі `webhooks.json` файлында баптаулады. Файл объектілер массивін қамтиды: + +```json +[ + { + "url": "https://example.com/webhook", + "events": ["payment.success", "payment.failed", "payment.expired"], + "secret": "your-webhook-secret" + } +] +``` + +| Өріс | Түрі | Міндетті | Сипаттама | +|---|---|---|---| +| `url` | `string` | ✅ | Хабарламалар жіберілетін URL | +| `events` | `string[]` | ✅ | Жазылу оқиғаларының тізімі | +| `secret` | `string` | ❌ | HMAC қолтаңбасы үшін құпия (ұсынылады) | + +> 💡 Бастау үшін `webhooks.example.json` → `webhooks.json` көшіріп, өңдеңіз. + +Әр түрлі URL және оқиғалармен бірнеше вебхук көрсетуге болады: + +```json +[ + { + "url": "https://my-crm.com/kaspi-hook", + "events": ["payment.success"], + "secret": "crm-secret-key" + }, + { + "url": "https://my-accounting.com/hook", + "events": ["payment.success", "payment.failed", "payment.expired"], + "secret": "accounting-secret" + } +] +``` + +### Оқиғалар + +| Оқиға | Сипаттама | Қашан іске қосылады | +|---|---|---| +| `payment.success` | Төлем сәтті өтті | QR: `Processed` статусы; Invoice: `Processed` статусы | +| `payment.failed` | Төлем қабылданбады / бас тартылды | QR: `CancelledByUser`, `Rejected`, `Error` және т.б.; Invoice: `RemotePaymentCanceled`, `RemotePaymentRejected` | +| `payment.expired` | Төлем уақыты аяқталды | QR: `QrTokenDiscarded`, `Expired`; Invoice: `Expired` | + +### Payload форматы + +Оқиға іске қосылғанда әрбір жазылған URL-ға JSON денесі бар POST сұрау жіберіледі: + +```json +{ + "event": "payment.success", + "paymentId": "123456", + "type": "qr", + "status": "Processed", + "statusDesc": "Операция сәтті өтті", + "amount": 5000, + "qrToken": "QR-TOKEN-...", + "receiptUrl": "https://...", + "orderNumber": "ORDER-001", + "data": { ... }, + "timestamp": "2026-05-10T00:00:00.000Z" +} +``` + +| Өріс | Түрі | Сипаттама | +|---|---|---| +| `event` | `string` | Оқиға атауы (`payment.success`, `payment.failed`, `payment.expired`) | +| `paymentId` | `string` | Төлем ID-сі (QR operationId немесе invoice operationId) | +| `type` | `string` | Төлем түрі: `qr` немесе `invoice` | +| `status` | `string` | Kaspi API-ден финалды статус | +| `statusDesc` | `string` | Статус сипаттамасы | +| `amount` | `number\|null` | Төлем сомасы теңгемен | +| `qrToken` | `string\|null` | QR-токен (тек QR-төлемдер үшін) | +| `receiptUrl` | `string\|null` | Чекке сілтеме | +| `orderNumber` | `string\|null` | Тапсырыс нөмірі | +| `data` | `object` | Kaspi API-ден толық жауап деректері | +| `timestamp` | `string` | Хабарлама жіберу уақыты (ISO 8601) | + +### Қолтаңба (HMAC) + +Әрбір сұрау вебхук конфигурациясындағы `secret` көмегімен HMAC SHA-256 арқылы қолтаңбаланады. Қолтаңба тақырыпта жіберіледі: + +``` +X-Webhook-Signature: sha256= +``` + +**Қабылдаушы жағында қолтаңбаны тексеру (Node.js):** + +```javascript +import crypto from 'crypto'; + +const verifySignature = (body, signature, secret) => { + const expected = 'sha256=' + crypto + .createHmac('sha256', secret) + .update(body) + .digest('hex'); + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expected) + ); +}; + +// Сұрау өңдеушісінде: +const rawBody = JSON.stringify(req.body); // немесе raw body пайдаланыңыз +const sig = req.headers['x-webhook-signature']; +if (!verifySignature(rawBody, sig, 'your-webhook-secret')) { + return res.status(401).send('Invalid signature'); +} +``` + +### Қайта жіберу (Retry) + +Вебхук жеткізілмесе (желі қатесі, таймаут, HTTP қатесі), жүйе **3 әрекетке** дейін өсетін кідіріспен орындайды: + +| Әрекет | Кідіріс | +|---|---| +| 1-ші (бірінші) | Бірден | +| 2-ші | 5 секунд | +| 3-ші | 30 секунд | + +- Сұрау таймауты: **10 секунд**. +- Қайта жіберу кезегі `webhook-retries.json` файлында сақталады және сервер қайта іске қосылғанда жоғалмайды. +- 3 сәтсіз әрекеттен кейін хабарлама жойылады (қате логқа жазылады). + +--- + +## Пайдаланудың типтік сценарийі + +``` +1. POST /api/auth/init → processId алу +2. POST /api/auth/send-phone → SMS жіберу +3. POST /api/auth/verify-otp → кодты растау → tokenSN + vtokenSecret алу + +4. POST /api/qr/create → төлем үшін QR жасау +5. GET /api/qr/status → төлем статусын тексеру + + — немесе — + +4. POST /api/invoice/create → телефон нөмірі бойынша шот-фактура жасау +5. GET /api/invoice/details → шот-фактура статусын тексеру + +6. POST /api/refund/create → қаражатты қайтару (қажет болған жағдайда) +``` diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..c4a0f3a --- /dev/null +++ b/docs/API.md @@ -0,0 +1,693 @@ +# 📖 API Documentation + +Kaspi POS Automation предоставляет REST API для работы с платежами Kaspi Pay: авторизация по SMS, выставление счетов, QR-оплата, история операций и возвраты. + +**Base URL:** `http://localhost:3000` + +--- + +## Содержание + +- [Аутентификация](#аутентификация) + - [Заголовки сессии](#заголовки-сессии) +- [Health Check](#health-check) +- [Auth — Авторизация](#auth--авторизация) + - [POST /api/auth/init](#post-apiauthinit) + - [POST /api/auth/send-phone](#post-apiauthsend-phone) + - [POST /api/auth/verify-otp](#post-apiauthverify-otp) + - [POST /api/auth/session](#post-apiauthsession) + - [POST /api/auth/logout](#post-apiauthlogout) +- [Invoice — Счета](#invoice--счета) + - [GET /api/invoice/client-info](#get-apiinvoiceclient-info) + - [POST /api/invoice/create](#post-apiinvoicecreate) + - [GET /api/invoice/details](#get-apiinvoicedetails) + - [POST /api/invoice/cancel](#post-apiinvoicecancel) + - [POST /api/invoice/history](#post-apiinvoicehistory) +- [QR — QR-оплата](#qr--qr-оплата) + - [POST /api/qr/create](#post-apiqrcreate) + - [GET /api/qr/status](#get-apiqrstatus) +- [History — История операций](#history--история-операций) + - [POST /api/history/operations](#post-apihistoryoperations) + - [POST /api/history/details](#post-apihistorydetails) +- [Refund — Возвраты](#refund--возвраты) + - [POST /api/refund/create](#post-apirefundcreate) +- [Session — Проверка сессии](#session--проверка-сессии) + - [GET /api/session/check](#get-apisessioncheck) +- [Webhooks — Уведомления](#webhooks--уведомления) + - [Настройка](#настройка) + - [События](#события) + - [Формат payload](#формат-payload) + - [Подпись (HMAC)](#подпись-hmac) + - [Повторные попытки (Retry)](#повторные-попытки-retry) + +--- + +## Аутентификация + +API использует 3-шаговую SMS-авторизацию. После успешной авторизации клиент получает `tokenSN` и `vtokenSecret`, которые передаются в заголовках для всех защищённых эндпоинтов. + +### Заголовки сессии + +Все эндпоинты кроме `/api/auth/*` и `/health` требуют следующие заголовки: + +| Заголовок | Тип | Обязательный | Описание | +|---|---|---|---| +| `X-Token-SN` | `string` | ✅ | Токен сессии, полученный при авторизации | +| `X-Vtoken-Secret` | `string` | ✅ | Зашифрованный секрет сессии | +| `X-Profile-Id` | `string` | ❌ | ID профиля организации | + +--- + +## Health Check + +### `GET /health` + +Проверка работоспособности сервера. + +**Ответ:** + +```json +{ "status": "ok" } +``` + +--- + +## Auth — Авторизация + +Трёхшаговый процесс авторизации через SMS-код Kaspi. + +> ⚠️ **Важно:** Для входа используйте номер телефона аккаунта **кассира** Kaspi Pay. + +### `POST /api/auth/init` + +Инициализация процесса авторизации. Возвращает `processId` для последующих шагов. + +**Тело запроса:** не требуется + +**Пример запроса:** + +```bash +curl -X POST http://localhost:3000/api/auth/init +``` + +**Успешный ответ:** + +```json +{ + "success": true, + "processId": "abc123-...", + "view": "EnterPhoneNumber", + "body": { ... } +} +``` + +--- + +### `POST /api/auth/send-phone` + +Отправка номера телефона — инициирует отправку SMS-кода. + +**Тело запроса:** + +| Поле | Тип | Обязательный | Описание | +|---|---|---|---| +| `phoneNumber` | `string` | ✅ | Номер телефона (формат: `7XXXXXXXXXX`) | +| `processId` | `string` | ✅ | ID процесса из `/api/auth/init` | + +**Пример запроса:** + +```bash +curl -X POST http://localhost:3000/api/auth/send-phone \ + -H "Content-Type: application/json" \ + -d '{"phoneNumber": "77001234567", "processId": "abc123-..."}' +``` + +**Успешный ответ:** + +```json +{ + "success": true, + "processId": "abc123-...", + "desc": "Код отправлен на номер +7 700 *** ** 67", + "view": "EnterOtp", + "body": { ... } +} +``` + +--- + +### `POST /api/auth/verify-otp` + +Подтверждение SMS-кода. При успехе автоматически завершает авторизацию и возвращает данные сессии. + +**Тело запроса:** + +| Поле | Тип | Обязательный | Описание | +|---|---|---|---| +| `otp` | `string` | ✅ | SMS-код | +| `processId` | `string` | ✅ | ID процесса из `/api/auth/init` | + +**Пример запроса:** + +```bash +curl -X POST http://localhost:3000/api/auth/verify-otp \ + -H "Content-Type: application/json" \ + -d '{"otp": "1234", "processId": "abc123-..."}' +``` + +**Успешный ответ:** + +```json +{ + "success": true, + "processId": "abc123-...", + "step": "finished", + "message": "OTP verified and finish completed", + "tokenSN": "TOKEN_SN_VALUE", + "vtokenSecret": "ENCRYPTED_SECRET", + "profileId": 12345, + "organizationId": 67890, + "orgName": "ИП Иванов", + "phone": "77001234567", + "organizations": [ ... ] +} +``` + +> ⚠️ Сохраните `tokenSN` и `vtokenSecret` — они нужны для всех последующих запросов. + +--- + +### `POST /api/auth/session` + +Проверка наличия токена (клиентская проверка). + +**Тело запроса:** + +| Поле | Тип | Обязательный | Описание | +|---|---|---|---| +| `tokenSN` | `string` | ❌ | Токен сессии | + +**Ответ:** + +```json +{ + "authenticated": true, + "tokenSN": "TOKEN_SN_VALUE" +} +``` + +--- + +### `POST /api/auth/logout` + +Завершение сессии. + +**Тело запроса:** не требуется + +**Ответ:** + +```json +{ "success": true } +``` + +--- + +## Invoice — Счета + +Выставление счетов на оплату по номеру телефона клиента. + +> 🔒 Все эндпоинты требуют [заголовки сессии](#заголовки-сессии). + +### `GET /api/invoice/client-info` + +Получение информации о клиенте по номеру телефона. + +**Query-параметры:** + +| Параметр | Тип | Обязательный | Описание | +|---|---|---|---| +| `phoneNumber` | `string` | ✅ | Номер телефона клиента | + +**Пример запроса:** + +```bash +curl "http://localhost:3000/api/invoice/client-info?phoneNumber=77001234567" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -H "X-Profile-Id: ..." +``` + +--- + +### `POST /api/invoice/create` + +Создание счёта на оплату. + +**Тело запроса:** + +| Поле | Тип | Обязательный | Описание | +|---|---|---|---| +| `phoneNumber` | `string` | ✅ | Номер телефона клиента | +| `amount` | `number` | ✅ | Сумма в тенге | +| `comment` | `string` | ❌ | Комментарий к платежу | + +**Пример запроса:** + +```bash +curl -X POST http://localhost:3000/api/invoice/create \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -H "X-Profile-Id: ..." \ + -d '{"phoneNumber": "77001234567", "amount": 1000, "comment": "Оплата заказа #42"}' +``` + +**Успешный ответ:** + +```json +{ + "StatusCode": 0, + "Data": { + "Id": 123456, + "Status": "RemotePaymentCreated", + "Amount": 1000, + "ClientMobile": "77001234567", + "ReceiptUrl": "https://...", + "OrderNumber": "..." + } +} +``` + +--- + +### `GET /api/invoice/details` + +Получение деталей счёта. + +**Query-параметры:** + +| Параметр | Тип | Обязательный | Описание | +|---|---|---|---| +| `operationId` | `string` | ✅ | ID операции | + +**Пример запроса:** + +```bash +curl "http://localhost:3000/api/invoice/details?operationId=123456" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." +``` + +--- + +### `POST /api/invoice/cancel` + +Отмена выставленного счёта. + +**Тело запроса:** + +| Поле | Тип | Обязательный | Описание | +|---|---|---|---| +| `operationId` | `string` | ✅ | ID операции для отмены | + +**Пример запроса:** + +```bash +curl -X POST http://localhost:3000/api/invoice/cancel \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -d '{"operationId": "123456"}' +``` + +--- + +### `POST /api/invoice/history` + +Получение истории выставленных счетов (последние 20). + +**Тело запроса:** не требуется (пустой JSON `{}`) + +**Пример запроса:** + +```bash +curl -X POST http://localhost:3000/api/invoice/history \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -d '{}' +``` + +--- + +## QR — QR-оплата + +Генерация QR-кодов для оплаты через Kaspi Pay. + +> 🔒 Все эндпоинты требуют [заголовки сессии](#заголовки-сессии). + +### `POST /api/qr/create` + +Создание QR-токена для оплаты. + +**Тело запроса:** + +| Поле | Тип | Обязательный | Описание | +|---|---|---|---| +| `amount` | `number` | ✅ | Сумма в тенге | +| `latitude` | `number` | ❌ | Широта (по умолчанию: Алматы) | +| `longitude` | `number` | ❌ | Долгота (по умолчанию: Алматы) | + +**Пример запроса:** + +```bash +curl -X POST http://localhost:3000/api/qr/create \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -H "X-Profile-Id: ..." \ + -d '{"amount": 500}' +``` + +**Успешный ответ:** + +```json +{ + "StatusCode": 0, + "Data": { + "QrOperationId": 789012, + "QrToken": "https://pay.kaspi.kz/pay/...", + "ExpireDate": "2025-01-01T12:05:00", + "Amount": 500, + "ReceiptUrl": "https://..." + } +} +``` + +> 💡 `QrToken` содержит ссылку для оплаты — можно преобразовать в QR-код. + +--- + +### `GET /api/qr/status` + +Проверка статуса QR-платежа. + +**Query-параметры:** + +| Параметр | Тип | Обязательный | Описание | +|---|---|---|---| +| `qrOperationId` | `string` | ✅ | ID QR-операции из `/api/qr/create` | + +**Пример запроса:** + +```bash +curl "http://localhost:3000/api/qr/status?qrOperationId=789012" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." +``` + +--- + +## History — История операций + +Просмотр истории всех операций (QR + счета). + +> 🔒 Все эндпоинты требуют [заголовки сессии](#заголовки-сессии). + +### `POST /api/history/operations` + +Получение списка операций за период. + +**Тело запроса:** + +| Поле | Тип | Обязательный | Описание | +|---|---|---|---| +| `endDate` | `string` | ✅ | Конечная дата (формат: `YYYY-MM-DD`) | +| `lastTransactionDate` | `string` | ❌ | Дата последней транзакции (для пагинации) | +| `statementPeriodCode` | `number` | ❌ | Код периода (по умолчанию: `0`) | + +**Пример запроса:** + +```bash +curl -X POST http://localhost:3000/api/history/operations \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -d '{"endDate": "2025-01-15"}' +``` + +--- + +### `POST /api/history/details` + +Получение деталей конкретной операции. + +**Тело запроса:** + +| Поле | Тип | Обязательный | Описание | +|---|---|---|---| +| `id` | `number` | ✅ | ID операции | +| `operationMethod` | `number` | ❌ | Метод операции (по умолчанию: `0`) | + +**Пример запроса:** + +```bash +curl -X POST http://localhost:3000/api/history/details \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -d '{"id": 123456}' +``` + +--- + +## Refund — Возвраты + +Возврат средств по ранее проведённой операции. + +> 🔒 Все эндпоинты требуют [заголовки сессии](#заголовки-сессии). + +### `POST /api/refund/create` + +Создание возврата. + +**Тело запроса:** + +| Поле | Тип | Обязательный | Описание | +|---|---|---|---| +| `qrOperationId` | `number` | ✅ | ID операции для возврата | +| `returnAmount` | `number` | ✅ | Сумма возврата в тенге | + +**Пример запроса:** + +```bash +curl -X POST http://localhost:3000/api/refund/create \ + -H "Content-Type: application/json" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." \ + -d '{"qrOperationId": 789012, "returnAmount": 500}' +``` + +--- + +## Session — Проверка сессии + +### `GET /api/session/check` + +Проверка валидности текущей сессии через запрос к Kaspi API. + +> 🔒 Требует [заголовки сессии](#заголовки-сессии). + +**Пример запроса:** + +```bash +curl "http://localhost:3000/api/session/check" \ + -H "X-Token-SN: ..." \ + -H "X-Vtoken-Secret: ..." +``` + +**Активная сессия:** + +```json +{ "active": true } +``` + +**Неактивная сессия:** + +```json +{ + "active": false, + "error": "Session rejected by Kaspi API.", + "code": 401, + "details": { ... } +} +``` + +--- + +## Коды ошибок + +Все эндпоинты возвращают ошибки в формате: + +```json +{ "error": "Описание ошибки" } +``` + +| HTTP-код | Описание | +|---|---| +| `400` | Отсутствуют обязательные параметры | +| `401` | Отсутствуют или невалидные заголовки сессии | +| `500` | Внутренняя ошибка сервера или ошибка Kaspi API | + +--- + +## Webhooks — Уведомления + +Система автоматически отслеживает статусы созданных QR- и invoice-платежей (polling каждые 3 секунды) и отправляет HTTP POST-уведомления (webhooks) на указанные URL при изменении статуса платежа. + +### Настройка + +Вебхуки настраиваются в файле `webhooks.json` в корне проекта. Файл содержит массив объектов: + +```json +[ + { + "url": "https://example.com/webhook", + "events": ["payment.success", "payment.failed", "payment.expired"], + "secret": "your-webhook-secret" + } +] +``` + +| Поле | Тип | Обязательный | Описание | +|---|---|---|---| +| `url` | `string` | ✅ | URL, на который будут отправляться уведомления | +| `events` | `string[]` | ✅ | Список событий для подписки | +| `secret` | `string` | ❌ | Секрет для HMAC-подписи (рекомендуется) | + +> 💡 Для начала скопируйте `webhooks.example.json` → `webhooks.json` и отредактируйте. + +Можно указать несколько вебхуков с разными URL и событиями: + +```json +[ + { + "url": "https://my-crm.com/kaspi-hook", + "events": ["payment.success"], + "secret": "crm-secret-key" + }, + { + "url": "https://my-accounting.com/hook", + "events": ["payment.success", "payment.failed", "payment.expired"], + "secret": "accounting-secret" + } +] +``` + +### События + +| Событие | Описание | Когда срабатывает | +|---|---|---| +| `payment.success` | Платёж успешно проведён | QR: статус `Processed`; Invoice: статус `Processed` | +| `payment.failed` | Платёж отклонён / отменён | QR: `CancelledByUser`, `Rejected`, `Error` и др.; Invoice: `RemotePaymentCanceled`, `RemotePaymentRejected` | +| `payment.expired` | Время оплаты истекло | QR: `QrTokenDiscarded`, `Expired`; Invoice: `Expired` | + +### Формат payload + +При срабатывании события на каждый подписанный URL отправляется POST-запрос с JSON-телом: + +```json +{ + "event": "payment.success", + "paymentId": "123456", + "type": "qr", + "status": "Processed", + "statusDesc": "Операция проведена успешно", + "amount": 5000, + "qrToken": "QR-TOKEN-...", + "receiptUrl": "https://...", + "orderNumber": "ORDER-001", + "data": { ... }, + "timestamp": "2026-05-10T00:00:00.000Z" +} +``` + +| Поле | Тип | Описание | +|---|---|---| +| `event` | `string` | Название события (`payment.success`, `payment.failed`, `payment.expired`) | +| `paymentId` | `string` | ID платежа (QR operationId или invoice operationId) | +| `type` | `string` | Тип платежа: `qr` или `invoice` | +| `status` | `string` | Финальный статус от Kaspi API | +| `statusDesc` | `string` | Описание статуса | +| `amount` | `number\|null` | Сумма платежа в тенге | +| `qrToken` | `string\|null` | QR-токен (только для QR-платежей) | +| `receiptUrl` | `string\|null` | Ссылка на чек | +| `orderNumber` | `string\|null` | Номер заказа | +| `data` | `object` | Полные данные ответа от Kaspi API | +| `timestamp` | `string` | Время отправки уведомления (ISO 8601) | + +### Подпись (HMAC) + +Каждый запрос подписывается HMAC SHA-256 с использованием `secret` из конфигурации вебхука. Подпись передаётся в заголовке: + +``` +X-Webhook-Signature: sha256= +``` + +**Проверка подписи на стороне получателя (Node.js):** + +```javascript +import crypto from 'crypto'; + +const verifySignature = (body, signature, secret) => { + const expected = 'sha256=' + crypto + .createHmac('sha256', secret) + .update(body) + .digest('hex'); + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expected) + ); +}; + +// В обработчике запроса: +const rawBody = JSON.stringify(req.body); // или используйте raw body +const sig = req.headers['x-webhook-signature']; +if (!verifySignature(rawBody, sig, 'your-webhook-secret')) { + return res.status(401).send('Invalid signature'); +} +``` + +### Повторные попытки (Retry) + +Если доставка вебхука не удалась (ошибка сети, таймаут, HTTP-ошибка), система выполняет до **3 попыток** с нарастающей задержкой: + +| Попытка | Задержка | +|---|---| +| 1-я (первая) | Немедленно | +| 2-я | 5 секунд | +| 3-я | 30 секунд | + +- Таймаут запроса: **10 секунд**. +- Очередь повторных попыток сохраняется в `webhook-retries.json` и переживает перезапуск сервера. +- После 3 неудачных попыток уведомление отбрасывается (логируется ошибка). + +--- + +## Типичный сценарий использования + +``` +1. POST /api/auth/init → получить processId +2. POST /api/auth/send-phone → отправить SMS +3. POST /api/auth/verify-otp → подтвердить код → получить tokenSN + vtokenSecret + +4. POST /api/qr/create → создать QR для оплаты +5. GET /api/qr/status → проверить статус оплаты + + — или — + +4. POST /api/invoice/create → выставить счёт по номеру телефона +5. GET /api/invoice/details → проверить статус счёта + +6. POST /api/refund/create → возврат средств (при необходимости) +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..d44d325 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,30 @@ +import js from '@eslint/js'; + +export default [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + URL: 'readonly', + setTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + clearTimeout: 'readonly', + fetch: 'readonly', + AbortController: 'readonly', + }, + }, + rules: { + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + 'no-console': 'off', + }, + }, + { + ignores: ['node_modules/', 'logs/', 'public/'], + }, +]; diff --git a/kaspi-pos-automation b/kaspi-pos-automation new file mode 160000 index 0000000..cc9b393 --- /dev/null +++ b/kaspi-pos-automation @@ -0,0 +1 @@ +Subproject commit cc9b393ca0e3a040a359997082ebcc64b3bb2099 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e11f188 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1847 @@ +{ + "name": "kaspi-pos-automation", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kaspi-pos-automation", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "dotenv": "^17.3.1", + "express": "^4.18.2", + "node-fetch": "^2.7.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.3.0", + "prettier": "^3.8.3" + }, + "engines": { + "node": ">=20.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c566fb1 --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "kaspi-pos-automation", + "version": "1.0.0", + "type": "module", + "description": "Автоматизация платежей для POS-систем через Kaspi Pay API", + "main": "server.js", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/tapter-dev/kaspi-pos-automation.git" + }, + "bugs": { + "url": "https://github.com/tapter-dev/kaspi-pos-automation/issues" + }, + "homepage": "https://github.com/tapter-dev/kaspi-pos-automation#readme", + "keywords": [ + "kaspi", + "kaspi-pay", + "pos", + "payment", + "automation", + "qr-payment", + "invoice" + ], + "engines": { + "node": ">=20.6.0" + }, + "scripts": { + "start": "node server.js", + "test": "node --test test/**/*.test.js", + "lint": "eslint .", + "format": "prettier --write .", + "regen:keypair": "node scripts/regen-keypair.js", + "regen:device": "node scripts/regen-device.js" + }, + "dependencies": { + "dotenv": "^17.3.1", + "express": "^4.18.2", + "node-fetch": "^2.7.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.3.0", + "prettier": "^3.8.3" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..ec0b4d0 --- /dev/null +++ b/public/app.js @@ -0,0 +1,738 @@ +// ─── Kaspi Pay — Frontend App ─── + +const API = ''; + +// ─── State ─── + +let currentOpId = null; +let invoicePollingTimer = null; +let historyOpId = null; +let qrPollingTimer = null; +let qrCountdownTimer = null; +let qrOperationId = null; + +// ─── Helpers ─── + +const $ = (id) => document.getElementById(id); +const digitsOnly = (str) => str.replace(/\D/g, ''); + +const getSession = () => { + try { + return JSON.parse(localStorage.getItem('kaspi_session') || '{}'); + } catch { + return {}; + } +}; + +const sessionHeaders = () => { + const s = getSession(); + const h = {}; + if (s.tokenSN) h['X-Token-SN'] = s.tokenSN; + if (s.profileId) h['X-Profile-ID'] = String(s.profileId); + if (s.vtokenSecret) h['X-Vtoken-Secret'] = s.vtokenSecret; + return h; +}; + +const apiFetch = async (path, opts = {}) => { + opts.headers = { ...sessionHeaders(), ...(opts.headers || {}) }; + const resp = await fetch(API + path, opts); + return resp.json(); +}; + +const apiPost = (path, body) => + apiFetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + ...(body !== undefined && { body: JSON.stringify(body) }), + }); + +// ─── Session Persistence (localStorage) ─── + +const SESSION_KEY = 'kaspi_session'; + +const saveSession = (data) => { + let prev = {}; + try { + prev = JSON.parse(localStorage.getItem(SESSION_KEY) || '{}'); + } catch {} + const merged = { ...prev, ...data }; + if (data.phone) merged.phoneNumber = data.phone; + localStorage.setItem(SESSION_KEY, JSON.stringify(merged)); +}; + +const clearSession = () => localStorage.removeItem(SESSION_KEY); + +const checkSession = async () => { + try { + const resp = await apiFetch('/api/session/check'); + if (resp.active === true) return { active: true }; + return { active: false, error: resp.error || 'Сессия неактивна' }; + } catch { + return { active: false, error: 'Ошибка проверки сессии' }; + } +}; + +const tryRestoreSession = async () => { + const session = getSession(); + if (session.tokenSN && session.vtokenSecret) { + showMainScreen(session); + // Verify session is still active on the server + const result = await checkSession(); + if (!result.active) { + clearSession(); + $('mainScreen').classList.add('hidden'); + $('authScreen').classList.remove('hidden'); + setAuthStep(1); + showAuthMsg(result.error || 'Сессия истекла. Войдите заново.', 'err'); + return false; + } + return true; + } + clearSession(); + return false; +}; + +const updateRefreshAuthBtn = () => { + const session = getSession(); + const btn = $('btnRefreshAuth'); + if (btn) btn.classList.toggle('hidden', !session.tokenSN || !session.vtokenSecret); +}; + +const formatPhone = (digits) => { + // Format up to 10 digits as "XXX XXX XX XX" + const d = digits.slice(0, 10); + if (d.length <= 3) return d; + if (d.length <= 6) return `${d.slice(0, 3)} ${d.slice(3)}`; + if (d.length <= 8) return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6)}`; + return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6, 8)} ${d.slice(8)}`; +}; + +const attachPhoneFormatter = (el) => { + el.addEventListener('input', () => { + const digits = digitsOnly(el.value); + const formatted = formatPhone(digits); + if (el.value !== formatted) el.value = formatted; + }); +}; + +window.addEventListener('DOMContentLoaded', () => { + updateRefreshAuthBtn(); + tryRestoreSession(); + attachPhoneFormatter($('phoneInput')); + attachPhoneFormatter($('clientPhone')); +}); + +// ─── Auth UI Helpers ─── + +const setAuthStep = (n) => { + for (let i = 1; i <= 3; i++) { + $(`authStep${i}`).classList.toggle('hidden', i !== n); + $(`dot${i}`).className = `step-dot${i < n ? ' done' : i === n ? ' active' : ''}`; + } +}; + +const showAuthMsg = (msg, type) => { + const el = $('authMsg'); + if (!msg) { + el.classList.add('hidden'); + return; + } + el.className = `status-bar status-${type}`; + el.textContent = msg; + el.classList.remove('hidden'); +}; + +const resetAuth = () => { + setAuthStep(1); + $('otpInput').value = ''; + showAuthMsg('', ''); +}; + +// ─── Auth Flow ─── + +let authProcessId = null; + +const sendPhone = async () => { + const phone = digitsOnly($('phoneInput').value); + if (phone.length < 10) return showAuthMsg('Введите 10 цифр номера', 'err'); + + const btn = $('btnSendPhone'); + btn.disabled = true; + btn.innerHTML = 'Отправка...'; + showAuthMsg('', ''); + + try { + const init = await apiPost('/api/auth/init'); + if (!init.success) { + showAuthMsg(`Ошибка инициализации: ${JSON.stringify(init.body)}`, 'err'); + return; + } + + authProcessId = init.processId; + + const resp = await apiPost('/api/auth/send-phone', { phoneNumber: phone, processId: authProcessId }); + if (resp.success) { + $('otpDesc').textContent = resp.desc || `SMS отправлен на +7${phone}`; + setAuthStep(2); + } else { + showAuthMsg(`Ошибка: ${resp.body?.data?.desc || JSON.stringify(resp.body)}`, 'err'); + } + } catch (e) { + showAuthMsg(`Ошибка сети: ${e.message}`, 'err'); + } finally { + btn.disabled = false; + btn.textContent = 'Получить SMS код'; + } +}; + +const verifyOtp = async () => { + const otp = digitsOnly($('otpInput').value); + if (!otp) return showAuthMsg('Введите код', 'err'); + + const btn = $('btnVerifyOtp'); + btn.disabled = true; + btn.innerHTML = 'Проверка...'; + showAuthMsg('', ''); + + try { + const resp = await apiPost('/api/auth/verify-otp', { otp, processId: authProcessId }); + if (resp.success && resp.step === 'finished') { + saveSession(resp); + authProcessId = null; + showMainScreen(resp); + } else { + showAuthMsg(`Неверный код или ошибка: ${resp.body?.data?.desc || JSON.stringify(resp.body)}`, 'err'); + } + } catch (e) { + showAuthMsg(`Ошибка: ${e.message}`, 'err'); + } finally { + btn.disabled = false; + btn.textContent = 'Подтвердить'; + } +}; + +// ─── Main Screen ─── + +const showMainScreen = (data) => { + $('authScreen').classList.add('hidden'); + $('mainScreen').classList.remove('hidden'); + if (data) { + $('userName').textContent = data.phone || '—'; + $('userOrg').textContent = data.orgName || '—'; + $('userAvatar').textContent = (data.orgName || 'K')[0].toUpperCase(); + } +}; + +const logout = async () => { + const { tokenSN } = getSession(); + clearSession(); + await apiPost('/api/auth/logout', { tokenSN }); + $('mainScreen').classList.add('hidden'); + $('authScreen').classList.remove('hidden'); + $('phoneInput').value = ''; + $('otpInput').value = ''; + setAuthStep(1); + showAuthMsg('', ''); +}; + + +const switchTab = (tab) => { + $('invoiceTab').classList.toggle('hidden', tab !== 'invoice'); + $('qrTab').classList.toggle('hidden', tab !== 'qr'); + $('historyTab').classList.toggle('hidden', tab !== 'history'); + $('salesTab').classList.toggle('hidden', tab !== 'sales'); + $('tabInvoice').classList.toggle('active', tab === 'invoice'); + $('tabQr').classList.toggle('active', tab === 'qr'); + $('tabHistory').classList.toggle('active', tab === 'history'); + $('tabSales').classList.toggle('active', tab === 'sales'); + if (tab === 'history') loadHistory(); + if (tab === 'sales') loadSales(); +}; + +// ─── Invoice ─── + +const statusBadge = (status) => { + const map = { + RemotePaymentCreated: ['Ожидает оплаты', 'pending'], + RemotePaymentPaid: ['Оплачен', 'paid'], + RemotePaymentCanceled: ['Отменён', 'canceled'], + RemotePaymentExpired: ['Истёк', 'expired'], + }; + const [label, cls] = map[status] || [status, 'pending']; + return `${label}`; +}; + +const renderDetails = (data, containerId) => { + if (!data) return; + const el = $(containerId); + const rows = (data.DynamicDetails || []) + .sort((a, b) => a.Order - b.Order) + .map( + ({ Title, Data, IsBold }) => + `
+ ${Title} + ${Data} +
`, + ) + .join(''); + el.innerHTML = `
${statusBadge(data.Status)}
${rows}`; +}; + +const stopInvoicePolling = () => { + if (invoicePollingTimer) { + clearInterval(invoicePollingTimer); + invoicePollingTimer = null; + } +}; + +const refreshInvoice = async () => { + if (!currentOpId) return null; + try { + const resp = await apiFetch(`/api/invoice/details?operationId=${currentOpId}`); + renderDetails(resp.Data, 'invoiceDetails'); + const { Status: status } = resp.Data || {}; + $('btnCancel').classList.toggle('hidden', status !== 'RemotePaymentCreated'); + if (status && status !== 'RemotePaymentCreated') stopInvoicePolling(); + return status; + } catch (e) { + console.error(e); + return null; + } +}; + +const startInvoicePolling = () => { + stopInvoicePolling(); + refreshInvoice(); + invoicePollingTimer = setInterval(refreshInvoice, 5000); +}; + +const createInvoice = async () => { + const phone = digitsOnly($('clientPhone').value); + const amount = $('invoiceAmount').value; + const comment = $('invoiceComment').value || 'Оплата'; + if (!phone || !amount) return alert('Заполните телефон и сумму'); + + const btn = $('btnCreate'); + btn.disabled = true; + btn.innerHTML = 'Создание...'; + + try { + const resp = await apiPost('/api/invoice/create', { + phoneNumber: phone, + amount: Number(amount), + comment, + }); + + if (resp.Data?.QrOperationId) { + currentOpId = resp.Data.QrOperationId; + $('invoiceOpId').textContent = `#${currentOpId}`; + $('invoiceResult').classList.remove('hidden'); + $('clientPhone').value = ''; + $('invoiceAmount').value = ''; + $('invoiceComment').value = ''; + $('clientInfo').classList.add('hidden'); + startInvoicePolling(); + } else { + alert(`Ошибка: ${resp.Message || JSON.stringify(resp)}`); + } + } catch (e) { + alert(`Ошибка: ${e.message}`); + } finally { + btn.disabled = false; + btn.textContent = 'Выставить счёт'; + } +}; + +const cancelInvoice = async () => { + if (!currentOpId || !confirm('Отменить счёт?')) return; + try { + await apiPost('/api/invoice/cancel', { operationId: currentOpId }); + refreshInvoice(); + } catch (e) { + alert(`Ошибка: ${e.message}`); + } +}; + +// ─── QR Code ─── + +const FINAL_QR_STATUSES = [ + 'Paid', + 'CancelledByUser', + 'NotConfirmedByUser', + 'QrTokenDiscarded', + 'ProcessingFailed', + 'InsufficientFunds', + 'Error', +]; + +const qrStatusBadge = (status) => { + const map = { + QrTokenCreated: ['Ожидание сканирования', 'info'], + QrTokenScanned: ['Отсканирован', 'info'], + PaymentConfirmation: ['Подтверждение оплаты...', 'warn'], + Paid: ['Оплачено ✅', 'ok'], + CancelledByUser: ['Отменено клиентом', 'err'], + NotConfirmedByUser: ['Не подтверждено', 'err'], + QrTokenDiscarded: ['QR не отсканирован', 'err'], + ProcessingFailed: ['Ошибка обработки', 'err'], + InsufficientFunds: ['Недостаточно средств', 'err'], + Error: ['Ошибка', 'err'], + }; + const [label, cls] = map[status] || [status, 'info']; + return { label, cls }; +}; + +const stopQrPolling = () => { + if (qrPollingTimer) { + clearInterval(qrPollingTimer); + qrPollingTimer = null; + } + if (qrCountdownTimer) { + clearInterval(qrCountdownTimer); + qrCountdownTimer = null; + } +}; + +const pollQrStatus = async () => { + if (!qrOperationId) return; + try { + const resp = await apiFetch(`/api/qr/status?qrOperationId=${qrOperationId}`); + const status = resp.Data?.Status; + if (status) { + const { label, cls } = qrStatusBadge(status); + const el = $('qrStatus'); + el.className = `status-bar status-${cls}`; + el.textContent = label; + } + if (FINAL_QR_STATUSES.includes(status)) { + stopQrPolling(); + } + } catch (e) { + console.error('QR polling error:', e); + } +}; + +const startQrCountdown = (seconds) => { + let remaining = seconds; + const timerEl = $('qrTimer'); + const tick = () => { + const m = Math.floor(remaining / 60); + const s = remaining % 60; + timerEl.textContent = `Осталось: ${m}:${String(s).padStart(2, '0')}`; + if (remaining <= 0) { + timerEl.textContent = 'Время истекло'; + stopQrPolling(); + } + remaining--; + }; + tick(); + qrCountdownTimer = setInterval(tick, 1000); +}; + +const generateQrSvg = (text, size = 256) => { + // Simple QR placeholder using a data URL image via an API + // For production, use a proper QR library; here we use a public API fallback + return `QR Code`; +}; + +const createQr = async () => { + const amount = $('qrAmount').value; + if (!amount) return alert('Введите сумму'); + + const btn = $('btnCreateQr'); + btn.disabled = true; + btn.innerHTML = 'Создание...'; + + stopQrPolling(); + + try { + const resp = await apiPost('/api/qr/create', { amount: Number(amount) }); + + if (resp.Data?.QrToken) { + qrOperationId = resp.Data.QrOperationId; + const options = resp.Data.QrPaymentBehaviorOptions || {}; + const pollInterval = (parseInt(options.qrCodeScanEventPollingInterval) || 3) * 1000; + const waitTimeout = parseInt(options.qrCodeScanWaitTimeout) || 180; + + $('qrCodeContainer').innerHTML = generateQrSvg(resp.Data.QrToken); + $('qrStatus').className = 'status-bar status-info'; + $('qrStatus').textContent = 'Ожидание сканирования...'; + $('qrResult').classList.remove('hidden'); + $('qrAmount').value = ''; + + startQrCountdown(waitTimeout); + qrPollingTimer = setInterval(pollQrStatus, pollInterval); + } else { + alert(`Ошибка: ${resp.Message || JSON.stringify(resp)}`); + } + } catch (e) { + alert(`Ошибка: ${e.message}`); + } finally { + btn.disabled = false; + btn.textContent = 'Создать QR'; + } +}; + +const resetQr = () => { + stopQrPolling(); + qrOperationId = null; + $('qrResult').classList.add('hidden'); + $('qrCodeContainer').innerHTML = ''; + $('qrTimer').textContent = ''; +}; + +// ─── Client Phone Lookup ─── + +$('clientPhone').addEventListener('blur', async function () { + const phone = digitsOnly(this.value); + const info = $('clientInfo'); + if (phone.length < 10) { + info.classList.add('hidden'); + return; + } + try { + const resp = await apiFetch(`/api/invoice/client-info?phoneNumber=${phone}`); + if (resp.Data?.ClientName) { + info.textContent = `✓ ${resp.Data.ClientName} (${resp.Data.ClientStatus})`; + info.style.background = '#e8f5e9'; + info.style.color = '#2e7d32'; + info.classList.remove('hidden'); + } else { + info.textContent = '✗ Клиент не найден'; + info.style.background = '#ffebee'; + info.style.color = '#c62828'; + info.classList.remove('hidden'); + } + } catch { + info.classList.add('hidden'); + } +}); + +// ─── History ─── + +const loadHistory = async () => { + const list = $('historyList'); + list.innerHTML = + '

Загрузка...

'; + try { + const resp = await apiPost('/api/invoice/history'); + const ops = resp.Data?.Operations || []; + if (!ops.length) { + list.innerHTML = '

Нет операций

'; + return; + } + list.innerHTML = ops + .map( + (op) => ` +
+
+
+
${op.ClientName || op.ClientShortName || '—'}
+
${new Date(op.OrderRegDate).toLocaleString('ru')}
+
+
+
${op.Amount}
+ ${statusBadge(op.Status)} +
+
+
`, + ) + .join(''); + } catch (e) { + list.innerHTML = `

Ошибка: ${e.message}

`; + } +}; + +const showHistoryDetail = async (opId) => { + historyOpId = opId; + const panel = $('historyDetail'); + const content = $('historyDetailContent'); + content.innerHTML = '

Загрузка...

'; + panel.classList.remove('hidden'); + try { + const resp = await apiFetch(`/api/invoice/details?operationId=${opId}`); + renderDetails(resp.Data, 'historyDetailContent'); + $('btnCancelFromHistory').classList.toggle('hidden', resp.Data?.Status !== 'RemotePaymentCreated'); + } catch { + content.innerHTML = '

Ошибка

'; + } +}; + +const cancelFromHistory = async () => { + if (!historyOpId || !confirm('Отменить счёт?')) return; + try { + await apiPost('/api/invoice/cancel', { operationId: historyOpId }); + showHistoryDetail(historyOpId); + loadHistory(); + } catch (e) { + alert(`Ошибка: ${e.message}`); + } +}; + +// ─── Sales (operations history + details + refund) ─── + +let salesOpId = null; + +const loadSales = async () => { + const list = $('salesList'); + const stats = $('salesStats'); + list.innerHTML = + '

Загрузка...

'; + stats.innerHTML = ''; + try { + const now = new Date(); + const endDate = + now.getFullYear() + + '-' + + String(now.getMonth() + 1).padStart(2, '0') + + '-' + + String(now.getDate()).padStart(2, '0') + + 'T23:59:59.000+0500'; + const resp = await apiPost('/api/history/operations', { endDate }); + const data = resp.Data || {}; + // Stats + const s = data.Statistic || {}; + stats.innerHTML = `
+
Продажи
${s.SalesAmount ?? 0} ₸
${s.SalesCount ?? 0} шт
+
Возвраты
${s.ReturnsAmount ?? 0} ₸
${s.ReturnsCount ?? 0} шт
+
`; + // Operations list + const dailySets = data.DailySets || []; + if (!dailySets.length) { + list.innerHTML = '

Нет операций

'; + return; + } + let html = ''; + for (const day of dailySets) { + html += `
${day.Date || ''}
`; + for (const op of day.Operations || []) { + const isReturn = op.OperationType === 1; + const sign = isReturn ? '+' : ''; + const color = isReturn ? '#c62828' : '#1a1a1a'; + html += `
+
+
+
${op.ClientName || op.ClientShortName || '—'}
+
${op.Time || ''}
+
+
+
${sign}${op.Amount} ₸
+
+
+
`; + } + } + list.innerHTML = html; + } catch (e) { + list.innerHTML = `

Ошибка: ${e.message}

`; + } +}; + +const showSalesDetail = async (id, operationMethod) => { + salesOpId = id; + const panel = $('salesDetail'); + const content = $('salesDetailContent'); + const refundSection = $('refundSection'); + const refundMsg = $('refundMsg'); + content.innerHTML = '

Загрузка...

'; + refundSection.classList.add('hidden'); + refundMsg.classList.add('hidden'); + panel.classList.remove('hidden'); + try { + const resp = await apiPost('/api/history/details', { id, operationMethod: operationMethod || 0 }); + const d = resp.Data || {}; + let rows = ''; + const fields = [ + ['Сумма', d.Amount ? `${d.Amount} ₸` : null], + ['Клиент', d.ClientName || d.ClientShortName], + ['Дата', d.OrderRegDate ? new Date(d.OrderRegDate).toLocaleString('ru') : null], + ['Статус', d.StatusDescription], + ['Доступно к возврату', d.AvailableReturnAmount != null ? `${d.AvailableReturnAmount} ₸` : null], + ['Тип возврата', d.PossibleReturnType], + ['Чек', d.ReceiptUrl ? `Открыть` : null], + ]; + for (const [label, value] of fields) { + if (value != null) + rows += `
${label}${value}
`; + } + // Returns history + if (d.Returns && d.Returns.length) { + rows += '
Возвраты:
'; + for (const r of d.Returns) { + rows += `
${r.Date || ''}${r.Amount} ₸
`; + } + } + content.innerHTML = rows || '

Нет данных

'; + // Show refund if available + const returnAmount = parseFloat(String(d.AvailableReturnAmount || '0').replace(/[^\d.]/g, '')); + if (returnAmount > 0) { + $('refundAmount').value = returnAmount; + $('refundAmount').max = returnAmount; + refundSection.classList.remove('hidden'); + } + } catch (e) { + content.innerHTML = `

Ошибка: ${e.message}

`; + } +}; + +const createRefund = async () => { + const amount = $('refundAmount').value; + if (!salesOpId || !amount) return alert('Укажите сумму возврата'); + if (!confirm(`Вернуть ${amount} ₸?`)) return; + const btn = $('btnRefund'); + const msg = $('refundMsg'); + btn.disabled = true; + btn.innerHTML = 'Возврат...'; + msg.classList.add('hidden'); + try { + const resp = await apiPost('/api/refund/create', { qrOperationId: salesOpId, returnAmount: Number(amount) }); + if (resp.StatusCode === 0) { + msg.className = 'status-bar status-ok'; + msg.textContent = 'Возврат выполнен успешно'; + } else { + msg.className = 'status-bar status-err'; + msg.textContent = resp.Description || resp.Message || 'Ошибка возврата'; + } + msg.classList.remove('hidden'); + // Refresh detail + showSalesDetail(salesOpId, 0); + } catch (e) { + msg.className = 'status-bar status-err'; + msg.textContent = `Ошибка: ${e.message}`; + msg.classList.remove('hidden'); + } finally { + btn.disabled = false; + btn.textContent = 'Сделать возврат'; + } +}; + +// ─── Expose to HTML onclick handlers ─── + +Object.assign(window, { + sendPhone, + verifyOtp, + resetAuth, + logout, + switchTab, + createInvoice, + refreshInvoice, + cancelInvoice, + createQr, + resetQr, + loadHistory, + showHistoryDetail, + cancelFromHistory, + loadSales, + showSalesDetail, + createRefund, +}); + +// ─── Init ─── + +(() => { + setAuthStep(1); + const session = getSession(); + if (session.tokenSN && session.vtokenSecret) { + showMainScreen(session); + } +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..d6a9d24 --- /dev/null +++ b/public/index.html @@ -0,0 +1,483 @@ + + + + + + Kaspi Pay + + + +
+

🏦 Kaspi Pay

+ + +
+
+
+
+
+
+
+ + +
+

Вход по номеру телефона

+

Войдите с аккаунта кассира Kaspi Pay

+ +
+ +7 + +
+ +
+ + + + + + + + +
+
+ + + +
+ + + + diff --git a/scripts/regen-device.js b/scripts/regen-device.js new file mode 100644 index 0000000..8214dea --- /dev/null +++ b/scripts/regen-device.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const FILE = path.join(ROOT, 'device.json'); + +if (fs.existsSync(FILE)) { + fs.renameSync(FILE, `${FILE}.bak`); + console.log('Backed up old device.json → device.json.bak'); +} + +const device = { + deviceId: crypto.randomUUID().toUpperCase(), + installId: crypto.randomUUID().toUpperCase(), + pinHash: crypto.createHash('md5').update(crypto.randomBytes(16)).digest('hex'), +}; +fs.writeFileSync(FILE, JSON.stringify(device, null, 2)); +console.log('Generated new device identity → device.json'); +console.log(` deviceId: ${device.deviceId}`); +console.log(` installId: ${device.installId}`); +console.log(` pinHash: ${device.pinHash}`); +console.log('⚠️ Re-authentication (SMS) required after device change.'); diff --git a/scripts/regen-keypair.js b/scripts/regen-keypair.js new file mode 100644 index 0000000..f865b00 --- /dev/null +++ b/scripts/regen-keypair.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const FILE = path.join(ROOT, 'keypair.json'); + +if (fs.existsSync(FILE)) { + fs.renameSync(FILE, `${FILE}.bak`); + console.log('Backed up old keypair.json → keypair.json.bak'); +} + +const keyPair = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); +const saved = { + privateKey: keyPair.privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64'), + publicKey: keyPair.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'), +}; +fs.writeFileSync(FILE, JSON.stringify(saved, null, 2)); +console.log('Generated new ECDSA keypair → keypair.json'); +console.log('⚠️ Re-authentication (SMS) required after keypair change.'); diff --git a/server.js b/server.js new file mode 100644 index 0000000..606a9a9 --- /dev/null +++ b/server.js @@ -0,0 +1,30 @@ +import express from 'express'; +import path from 'path'; +import { PORT, ROOT_DIR } from './src/config.js'; +import authRoutes from './src/routes/auth.js'; +import invoiceRoutes from './src/routes/invoice.js'; +import qrRoutes from './src/routes/qr.js'; +import historyRoutes from './src/routes/history.js'; +import refundRoutes from './src/routes/refund.js'; +import sessionRoutes from './src/routes/session.js'; +import { startPolling } from './src/polling.js'; +import 'dotenv/config'; + +const app = express(); + +app.use(express.json()); +app.use(express.static(path.join(ROOT_DIR, 'public'))); + +app.get('/health', (req, res) => res.json({ status: 'ok' })); + +app.use('/api/auth', authRoutes); +app.use('/api/invoice', invoiceRoutes); +app.use('/api/qr', qrRoutes); +app.use('/api/history', historyRoutes); +app.use('/api/refund', refundRoutes); +app.use('/api/session', sessionRoutes); + +app.listen(PORT, () => { + console.log(`\n 🟢 Kaspi Pay App running at http://localhost:${PORT}\n`); + startPolling(); +}); diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..cc4b922 --- /dev/null +++ b/src/config.js @@ -0,0 +1,113 @@ +import 'dotenv/config'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = path.resolve(__dirname, '..'); + +export const PORT = process.env.PORT || 3000; + +// ─── ECDSA P-256 keypair (persisted to keypair.json) ─── + +const KEYPAIR_FILE = path.join(ROOT_DIR, 'keypair.json'); + +let ecKeyPair; +if (fs.existsSync(KEYPAIR_FILE)) { + const saved = JSON.parse(fs.readFileSync(KEYPAIR_FILE, 'utf8')); + ecKeyPair = { + privateKey: crypto.createPrivateKey({ key: Buffer.from(saved.privateKey, 'base64'), format: 'der', type: 'pkcs8' }), + publicKey: crypto.createPublicKey({ key: Buffer.from(saved.publicKey, 'base64'), format: 'der', type: 'spki' }), + }; + console.log('Loaded ECDSA keypair from keypair.json'); +} else { + ecKeyPair = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const saved = { + privateKey: ecKeyPair.privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64'), + publicKey: ecKeyPair.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'), + }; + fs.writeFileSync(KEYPAIR_FILE, JSON.stringify(saved, null, 2)); + console.log('Generated new ECDSA keypair → saved to keypair.json'); +} + +export { ecKeyPair }; + +// Uncompressed EC public key point (base64) +const pubKeyDer = ecKeyPair.publicKey.export({ type: 'spki', format: 'der' }); +const x509B64 = pubKeyDer.toString('base64'); +const uncompressedPoint = pubKeyDer.slice(pubKeyDer.length - 65); +const pkB64 = uncompressedPoint.toString('base64'); +const pkTagHash = crypto.createHash('md5').update(pkB64).digest('hex'); + +// ─── Device identity (persisted to device.json) ─── + +const DEVICE_FILE = path.join(ROOT_DIR, 'device.json'); + +let deviceId, installId, pinHash; +if (fs.existsSync(DEVICE_FILE)) { + const saved = JSON.parse(fs.readFileSync(DEVICE_FILE, 'utf8')); + ({ deviceId, installId, pinHash } = saved); + console.log('Loaded device identity from device.json'); +} else { + deviceId = crypto.randomUUID().toUpperCase(); + installId = crypto.randomUUID().toUpperCase(); + pinHash = crypto.createHash('md5').update(crypto.randomBytes(16)).digest('hex'); + fs.writeFileSync(DEVICE_FILE, JSON.stringify({ deviceId, installId, pinHash }, null, 2)); + console.log('Generated new device identity → saved to device.json'); +} + +export const DEVICE = { + deviceId, + installId, + pk: pkB64, + pkTag: pkTagHash, + pinHash, + x509: x509B64, +}; + +console.log(' pk:', DEVICE.pk); +console.log(' x509:', DEVICE.x509); +console.log(' pkTag:', DEVICE.pkTag); + +// ─── Kaspi Base URLs ─── + +export const KASPI_ENTRANCE_URL = 'https://entrance-pay.kaspi.kz'; +export const KASPI_MTOKEN_URL = 'https://mtoken.kaspi.kz'; +export const KASPI_QRPAY_URL = 'https://qrpay.kaspi.kz'; + +// ─── App version & device constants ─── +// Defaults match a known-good Kaspi Pay client. Override via .env if needed. +// ⚠️ The Kaspi API validates these parameters and may reject unknown values. + +export const APP = { + version: process.env.APP_VERSION || '4.112.1', + build: process.env.APP_BUILD || '1107', + platform: process.env.APP_PLATFORM || 'iOS', + platformVer: process.env.APP_PLATFORM_VER || '18.4', + locale: process.env.APP_LOCALE || 'ru-RU', + model: process.env.APP_MODEL || 'iPhone16,2', + brand: process.env.APP_BRAND || 'Apple', + deviceName: process.env.APP_DEVICE_NAME || 'iPhone', + screenW: process.env.APP_SCREEN_W || '430.0', + screenH: process.env.APP_SCREEN_H || '932.0', + cfNetwork: process.env.APP_CFNETWORK || 'CFNetwork/3826.400.120', + darwin: process.env.APP_DARWIN || 'Darwin/24.4.0', +}; + +export const UA_NATIVE = `Kaspi%20Pay/${APP.build} ${APP.cfNetwork} ${APP.darwin}`; +export const UA_BROWSER = `Mozilla/5.0 (iPhone; CPU iPhone OS ${APP.platformVer.replace('.', '_')} like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148`; + +export const ENTRANCE_HEADERS_BASE = { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + 'Accept-Language': 'ru', + 'Accept-Encoding': 'gzip, deflate, br', + Origin: KASPI_ENTRANCE_URL, + 'Sec-Fetch-Site': 'same-origin', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Dest': 'empty', + 'User-Agent': UA_BROWSER, +}; + +export { ROOT_DIR }; diff --git a/src/crypto.js b/src/crypto.js new file mode 100644 index 0000000..e97ead0 --- /dev/null +++ b/src/crypto.js @@ -0,0 +1,161 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import {fileURLToPath} from 'url'; +import {ecKeyPair} from './config.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ECDH_FILE = path.join(__dirname, '..', 'ecdh-keypair.json'); + +// ─── ECDH ─── + +const vtokenSuite = 'OCRA-1:HOTP-SHA256-6:QH64-T1M'; + +// ─── AES-256-GCM encryption for vtokenSecret ─── + +if (!process.env.TOKEN_SECRET_KEY) { + console.error('FATAL: TOKEN_SECRET_KEY environment variable is not set.'); + console.error('Generate one with: echo "TOKEN_SECRET_KEY=$(openssl rand -hex 32)" > .env'); + process.exit(1); +} +const ENCRYPTION_KEY = Buffer.from(process.env.TOKEN_SECRET_KEY, 'hex'); + +export const encryptSecret = (secretBuffer) => { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); + const encrypted = Buffer.concat([cipher.update(secretBuffer), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, encrypted]).toString('base64'); +}; + +export const decryptSecret = (tokenB64) => { + const buf = Buffer.from(tokenB64, 'base64'); + const iv = buf.subarray(0, 12); + const tag = buf.subarray(12, 28); + const encrypted = buf.subarray(28); + const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]); +}; + +let lastEcdhKeyPair = null; + +export const generateECDH = () => { + lastEcdhKeyPair = crypto.generateKeyPairSync('ec', {namedCurve: 'prime256v1'}); + // Persist ECDH private key so refresh (SignInLite) can reuse it + const saved = { + privateKey: lastEcdhKeyPair.privateKey.export({type: 'pkcs8', format: 'der'}).toString('base64'), + publicKey: lastEcdhKeyPair.publicKey.export({type: 'spki', format: 'der'}).toString('base64'), + }; + fs.writeFileSync(ECDH_FILE, JSON.stringify(saved, null, 2)); + const spki = lastEcdhKeyPair.publicKey.export({type: 'spki', format: 'der'}); + return spki.toString('base64'); +}; + +export const completeECDH = (serverX509B64) => { + if (!lastEcdhKeyPair) throw new Error('No ECDH keypair generated'); + const serverPubKey = crypto.createPublicKey({ + key: Buffer.from(serverX509B64, 'base64'), + format: 'der', + type: 'spki', + }); + const secret = crypto.diffieHellman({ + privateKey: lastEcdhKeyPair.privateKey, + publicKey: serverPubKey, + }); + console.log('ECDH shared secret derived, length:', secret.length); + lastEcdhKeyPair = null; + return secret; +}; + +export const completeECDHWithSaved = (serverX509B64) => { + if (!fs.existsSync(ECDH_FILE)) throw new Error('No saved ECDH keypair (ecdh-keypair.json missing)'); + const saved = JSON.parse(fs.readFileSync(ECDH_FILE, 'utf8')); + const privateKey = crypto.createPrivateKey({ + key: Buffer.from(saved.privateKey, 'base64'), + format: 'der', + type: 'pkcs8', + }); + const serverPubKey = crypto.createPublicKey({ + key: Buffer.from(serverX509B64, 'base64'), + format: 'der', + type: 'spki', + }); + const secret = crypto.diffieHellman({privateKey, publicKey: serverPubKey}); + console.log('ECDH (saved key) shared secret derived, length:', secret.length); + return secret; +}; + +// ─── Helpers ─── + +const hexToBytes = (hex) => { + const bytes = []; + for (let i = 0; i < hex.length; i += 2) { + bytes.push(parseInt(hex.substring(i, i + 2), 16)); + } + return Buffer.from(bytes); +}; + +// ─── OCRA-1 TOTP (matches Kaspi vtoken) ─── + +export const computeTokenSnMac = (tokenSN, secret) => { + if (!secret) return '000000'; + + const timeStep = BigInt(Date.now()) / BigInt(30000); + const timeHex = timeStep.toString(16); + + const qHex = Buffer.from(tokenSN || '00000000') + .toString('hex') + .substring(0, 64); + + const suiteBytes = Buffer.from(vtokenSuite); + const separator = Buffer.from([0x00]); + + const qPadded = qHex.padEnd(256, '0'); + const qBytes = hexToBytes(qPadded); + + const tPadded = timeHex.padStart(16, '0'); + const tBytes = hexToBytes(tPadded); + + const dataBuffer = Buffer.concat([suiteBytes, separator, qBytes, tBytes]); + + const hash = crypto.createHmac('sha256', secret).update(dataBuffer).digest(); + + // Dynamic truncation (RFC 4226) + const offset = hash[hash.length - 1] & 0x0f; + const binCode = + ((hash[offset] & 0x7f) << 24) | + ((hash[offset + 1] & 0xff) << 16) | + ((hash[offset + 2] & 0xff) << 8) | + (hash[offset + 3] & 0xff); + + return (binCode % 1000000).toString().padStart(6, '0'); +}; + +// ─── ECDSA signing ─── + +export const ecSign = (data) => { + const sign = crypto.createSign('SHA256'); + sign.update(data); + sign.end(); + return sign.sign(ecKeyPair.privateKey).toString('base64'); +}; + +export const signDataPayload = (dataB64) => ecSign(dataB64); + +export const computeXSU = (url) => crypto.createHash('md5').update(url.toLowerCase()).digest('hex'); + +export const computeXSign = (url, headers, xshList) => { + const parts = xshList.split(',').map((name) => { + if (name === 'url') { + try { + const u = new URL(url); + return u.pathname + u.search; + } catch { + return url; + } + } + return headers[name] || ''; + }); + return ecSign(parts.join('')); +}; diff --git a/src/helpers.js b/src/helpers.js new file mode 100644 index 0000000..63e5bf2 --- /dev/null +++ b/src/helpers.js @@ -0,0 +1,105 @@ +import crypto from 'crypto'; +import fetch from 'node-fetch'; +import { DEVICE, APP, UA_NATIVE } from './config.js'; +import { computeTokenSnMac, computeXSign } from './crypto.js'; + +// ─── Utilities ─── + +export const generateUUID = () => crypto.randomUUID().toUpperCase(); + +export const nowISO = () => { + const d = new Date(); + const off = -d.getTimezoneOffset(); + const sign = off >= 0 ? '+' : '-'; + const hh = String(Math.floor(Math.abs(off) / 60)).padStart(2, '0'); + const mm = String(Math.abs(off) % 60).padStart(2, '0'); + return ( + d + .toISOString() + .replace('Z', '') + .replace(/\.\d{3}/, `.${String(d.getMilliseconds()).padStart(3, '0')}`) + + sign + + hh + + mm + ); +}; + +// ─── Cookie builder ─── + +export const entranceCookie = (extraUserToken) => { + let c = `deviceId=${DEVICE.deviceId}; installId=${DEVICE.installId}; is_mobile_app=true; locale=${APP.locale}; ma_bld=${APP.build}; ma_platform_type=${APP.platform}; ma_platform_ver=${APP.platformVer}; ma_ver=${APP.version}; pk=${DEVICE.pk}; pkTag=${DEVICE.pkTag}; xs=R:0|E:0|RH:0|N:0`; + if (extraUserToken) c += `; user_token=${extraUserToken}`; + return c; +}; + +// ─── Extract user_token from set-cookie ─── + +export const extractUserToken = (resp) => { + const raw = resp.headers.raw()['set-cookie'] || []; + for (const c of raw) { + const m = c.match(/user_token=([^;]+)/); + if (m) return m[1]; + } + return null; +}; + +// ─── Logged fetch wrapper ─── + +export const loggedFetch = async (url, options = {}) => { + const method = (options.method || 'GET').toUpperCase(); + console.log(`\n>>> ${method} ${url}`); + if (options.headers) console.log('>>> Headers:', JSON.stringify(options.headers, null, 2)); + if (options.body) { + try { + console.log('>>> Body:', JSON.parse(options.body)); + } catch { + console.log('>>> Body:', options.body); + } + } + + const resp = await fetch(url, options); + const cloned = resp.clone(); + let body; + try { + body = await cloned.json(); + } catch { + try { + body = await cloned.text(); + } catch { + body = '[unreadable]'; + } + } + console.log(`<<< ${resp.status} ${resp.statusText}`); + console.log('<<< Response:', typeof body === 'object' ? JSON.stringify(body, null, 2) : body); + return resp; +}; + +// ─── Signed QR-pay headers (session passed as parameter) ─── + +export const signedQrPayHeaders = (url, session) => { + const xsh = + 'url,X-Request-ID,X-Device-ID,X-Platform-Ver,X-App-Bld,X-Time,X-Kb-TokenSn,X-App-Ver,X-Kb-TokenSnMac,X-Call,X-PI,X-Install-ID,X-Platform-Type,X-Locale,X-SV'; + const headers = { + 'X-Kb-TokenSn': session.tokenSN, + 'X-Kb-TokenSnMac': computeTokenSnMac(session.tokenSN, session.decryptedSecret), + 'X-PI': session.profileId != null ? String(session.profileId) : '', + 'X-Install-ID': DEVICE.installId, + 'X-Device-ID': DEVICE.deviceId, + 'X-App-Ver': APP.version, + 'X-App-Bld': APP.build, + 'X-Platform-Type': APP.platform, + 'X-Platform-Ver': APP.platformVer, + 'X-Locale': APP.locale, + 'X-Time': nowISO(), + 'X-Request-ID': generateUUID(), + 'X-Call': 'notConnected', + 'X-SV': '2', + 'X-SH': xsh, + 'User-Agent': UA_NATIVE, + Accept: '*/*', + 'Accept-Language': 'ru', + 'Accept-Encoding': 'gzip, deflate, br', + }; + headers['X-Sign'] = computeXSign(url, headers, xsh); + return headers; +}; diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..1a349ea --- /dev/null +++ b/src/logger.js @@ -0,0 +1,50 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const LOGS_DIR = path.join(__dirname, '..', 'logs'); + +// Ensure logs directory exists +if (!fs.existsSync(LOGS_DIR)) { + fs.mkdirSync(LOGS_DIR, { recursive: true }); +} + +const getLogFile = () => { + const now = new Date(); + const date = now.toISOString().slice(0, 10); // YYYY-MM-DD + return path.join(LOGS_DIR, `${date}.log`); +}; + +const formatTimestamp = () => { + return new Date().toISOString(); +}; + +const writeLine = (level, tag, message, extra) => { + const ts = formatTimestamp(); + let line = `[${ts}] [${level}] [${tag}] ${message}`; + if (extra !== undefined) { + line += typeof extra === 'string' ? ` ${extra}` : ` ${JSON.stringify(extra)}`; + } + line += '\n'; + + // Write to file + try { + fs.appendFileSync(getLogFile(), line); + } catch (err) { + console.error('Failed to write log:', err.message); + } + + // Also output to console + if (level === 'ERROR') { + console.error(line.trimEnd()); + } else { + console.log(line.trimEnd()); + } +}; + +export const logger = { + info: (tag, message, extra) => writeLine('INFO', tag, message, extra), + warn: (tag, message, extra) => writeLine('WARN', tag, message, extra), + error: (tag, message, extra) => writeLine('ERROR', tag, message, extra), +}; diff --git a/src/polling.js b/src/polling.js new file mode 100644 index 0000000..3ed0853 --- /dev/null +++ b/src/polling.js @@ -0,0 +1,394 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import fetch from 'node-fetch'; +import { fileURLToPath } from 'url'; +import { KASPI_QRPAY_URL } from './config.js'; +import { signedQrPayHeaders } from './helpers.js'; +import { decryptSecret } from './crypto.js'; +import { getWebhooksByEvent } from './webhookStore.js'; +import { logger } from './logger.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TRACKED_FILE = path.join(__dirname, '..', 'tracked-payments.json'); + +// ─── Tracked payments ─── + +const trackedPayments = new Map(); + +// ─── Persistence ─── + +const saveTracked = () => { + try { + const data = Object.fromEntries(trackedPayments); + fs.writeFileSync(TRACKED_FILE, JSON.stringify(data, null, 2)); + } catch (err) { + logger.error('POLLING', 'Failed to save tracked payments', err.message); + } +}; + +const loadTracked = () => { + try { + if (!fs.existsSync(TRACKED_FILE)) return; + const raw = fs.readFileSync(TRACKED_FILE, 'utf8'); + const data = JSON.parse(raw); + for (const [id, entry] of Object.entries(data)) { + trackedPayments.set(id, entry); + } + if (trackedPayments.size > 0) { + logger.info('POLLING', `Restored ${trackedPayments.size} tracked payments from file`); + } + } catch (err) { + logger.error('POLLING', 'Failed to load tracked payments', err.message); + } +}; + +// ─── Pending retries (persisted) ─── + +const RETRY_FILE = path.join(__dirname, '..', 'webhook-retries.json'); +let pendingRetries = []; + +const saveRetries = () => { + try { + fs.writeFileSync(RETRY_FILE, JSON.stringify(pendingRetries, null, 2)); + } catch (err) { + logger.error('WEBHOOK', 'Failed to save retries', err.message); + } +}; + +const loadRetries = () => { + try { + if (!fs.existsSync(RETRY_FILE)) return; + const raw = fs.readFileSync(RETRY_FILE, 'utf8'); + pendingRetries = JSON.parse(raw); + if (pendingRetries.length > 0) { + logger.info('WEBHOOK', `Restored ${pendingRetries.length} pending retries from file`); + } + } catch (err) { + logger.error('WEBHOOK', 'Failed to load retries', err.message); + pendingRetries = []; + } +}; + +// ─── Status → event mapping ─── + +const QR_FINAL_STATUSES = { + Processed: 'payment.success', + CancelledByUser: 'payment.failed', + NotConfirmedByUser: 'payment.failed', + CancelledByExternalSource: 'payment.failed', + ProcessingFailed: 'payment.failed', + Rejected: 'payment.failed', + InsufficientFunds: 'payment.failed', + InsufficientFundsError: 'payment.failed', + Error: 'payment.failed', + IrisSrcBlockCode1: 'payment.failed', + IrisSrcBlockCode3: 'payment.failed', + IrisSrcBlockCode9: 'payment.failed', + IrisDestBlockCode3: 'payment.failed', + IrisDestBlockCode5: 'payment.failed', + IrisDestBlockCode7: 'payment.failed', + IrisDestBlockCode10: 'payment.failed', + QrTokenDiscarded: 'payment.expired', + Expired: 'payment.expired', +}; + +const INVOICE_FINAL_STATUSES = { + Processed: 'payment.success', + RemotePaymentCanceled: 'payment.failed', + RemotePaymentRejected: 'payment.failed', + Expired: 'payment.expired', +}; + +const QR_INTERMEDIATE = new Set(['QrTokenCreated', 'Wait']); +const INVOICE_INTERMEDIATE = new Set(['RemotePaymentCreated']); + +// ─── Track a payment ─── + +export const trackPayment = (paymentId, type, sessionHeaders, meta = {}) => { + trackedPayments.set(String(paymentId), { + paymentId: String(paymentId), + type, + status: type === 'qr' ? 'QrTokenCreated' : 'RemotePaymentCreated', + sessionHeaders, + meta, + createdAt: Date.now(), + retryCount: 0, + }); + saveTracked(); + logger.info('POLLING', `Tracking ${type} payment ${paymentId}`); +}; + +// ─── Fetch status from Kaspi (quiet — no loggedFetch) ─── + +const fetchStatus = async (entry) => { + const { paymentId, type, sessionHeaders } = entry; + + let decryptedSecret; + try { + decryptedSecret = decryptSecret(sessionHeaders.vtokenSecret); + } catch { + logger.error('POLLING', `Failed to decrypt session for payment ${paymentId} — session may have expired`); + return { error: 'session_expired' }; + } + + const session = { + tokenSN: sessionHeaders.tokenSN, + decryptedSecret, + profileId: sessionHeaders.profileId, + }; + + let url; + if (type === 'qr') { + url = `${KASPI_QRPAY_URL}/v02/kaspi-qr/status?qrOperationId=${paymentId}`; + } else { + url = `${KASPI_QRPAY_URL}/v02/remote/details?operationId=${paymentId}`; + } + + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15000); + const resp = await fetch(url, { + headers: signedQrPayHeaders(url, session), + signal: controller.signal, + }); + clearTimeout(timer); + const json = await resp.json(); + return json; + } catch (err) { + logger.error('POLLING', `Error fetching status for ${paymentId}:`, err.message); + return null; + } +}; + +// ─── Send webhooks ─── + +const fetchWithTimeout = async (url, options, timeoutMs = 10000) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const resp = await fetch(url, { ...options, signal: controller.signal }); + clearTimeout(timer); + return resp; + } catch (err) { + clearTimeout(timer); + throw err; + } +}; + +const sendWebhook = async (hook, payload, attempt = 1) => { + const body = JSON.stringify(payload); + const signature = + 'sha256=' + + crypto + .createHmac('sha256', hook.secret || '') + .update(body) + .digest('hex'); + + try { + const resp = await fetchWithTimeout(hook.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Signature': signature, + }, + body, + }); + logger.info('WEBHOOK', `→ ${hook.url} | ${resp.status} ${resp.statusText}`); + // Remove from pending retries on success + pendingRetries = pendingRetries.filter( + (r) => + !(r.hook.url === hook.url && r.payload.paymentId === payload.paymentId && r.payload.event === payload.event), + ); + saveRetries(); + } catch (err) { + logger.error('WEBHOOK', `→ ${hook.url} | attempt ${attempt} FAILED: ${err.message}`); + if (attempt < 3) { + // Save retry to disk so it survives restarts + pendingRetries.push({ + hook, + payload, + attempt: attempt + 1, + executeAfter: Date.now() + (attempt === 1 ? 5000 : 30000), + }); + saveRetries(); + } else { + logger.error('WEBHOOK', `→ ${hook.url} | FAILED after 3 retries`); + // Remove from pending retries + pendingRetries = pendingRetries.filter( + (r) => + !(r.hook.url === hook.url && r.payload.paymentId === payload.paymentId && r.payload.event === payload.event), + ); + saveRetries(); + } + } +}; + +const sendWebhooks = (event, payload) => { + const hooks = getWebhooksByEvent(event); + for (const hook of hooks) { + sendWebhook(hook, payload); + } +}; + +// ─── Process pending retries ─── + +const processRetries = async () => { + const now = Date.now(); + const due = pendingRetries.filter((r) => r.executeAfter <= now); + // Remove due items from list before executing (they'll be re-added on failure) + pendingRetries = pendingRetries.filter((r) => r.executeAfter > now); + saveRetries(); + + for (const r of due) { + await sendWebhook(r.hook, r.payload, r.attempt); + } +}; + +// ─── Resolve event from status ─── + +const resolveEvent = (type, status) => { + if (type === 'qr') { + if (QR_INTERMEDIATE.has(status)) return null; + return QR_FINAL_STATUSES[status] || 'payment.failed'; + } else { + if (INVOICE_INTERMEDIATE.has(status)) return null; + return INVOICE_FINAL_STATUSES[status] || 'payment.failed'; + } +}; + +// ─── Poll cycle ─── + +const pollOnce = async () => { + let changed = false; + + for (const [id, entry] of trackedPayments) { + // TTL check via expireDate + if (entry.meta.expireDate) { + const expiry = new Date(entry.meta.expireDate).getTime(); + if (Date.now() > expiry && resolveEvent(entry.type, entry.status) === null) { + logger.info('POLLING', `Payment ${id} expired (TTL)`); + sendWebhooks( + 'payment.expired', + buildPayload('payment.expired', entry, { Status: 'Expired', StatusDesc: 'Время оплаты истекло' }), + ); + trackedPayments.delete(id); + changed = true; + continue; + } + } + + const result = await fetchStatus(entry); + + // Handle session expiration + if (result && result.error === 'session_expired') { + entry.retryCount++; + if (entry.retryCount > 3) { + logger.warn('POLLING', `Payment ${id} — session expired, sending session.expired webhook`); + sendWebhooks( + 'payment.failed', + buildPayload('payment.failed', entry, { + Status: 'SessionExpired', + StatusDesc: 'Сессия Kaspi истекла, невозможно проверить статус платежа', + }), + ); + trackedPayments.delete(id); + changed = true; + } + continue; + } + + if (!result || !result.Data) { + entry.retryCount++; + if (entry.retryCount > 10) { + logger.warn('POLLING', `Removing payment ${id} after 10 failed attempts`); + trackedPayments.delete(id); + changed = true; + } + continue; + } + + // Reset retry count on successful fetch + entry.retryCount = 0; + + const newStatus = result.Data.Status; + if (newStatus === entry.status) continue; + + logger.info('POLLING', `Payment ${id}: ${entry.status} → ${newStatus}`); + entry.status = newStatus; + changed = true; + + const event = resolveEvent(entry.type, newStatus); + if (event) { + sendWebhooks(event, buildPayload(event, entry, result.Data)); + trackedPayments.delete(id); + } + } + + if (changed) { + saveTracked(); + } +}; + +const buildPayload = (event, entry, data) => ({ + event, + paymentId: entry.paymentId, + type: entry.type, + status: data.Status || entry.status, + statusDesc: data.StatusDesc || '', + amount: entry.meta.amount || data.Amount || null, + qrToken: entry.meta.qrToken || null, + receiptUrl: entry.meta.receiptUrl || data.ReceiptUrl || null, + orderNumber: entry.meta.orderNumber || data.OrderNumber || null, + data, + timestamp: new Date().toISOString(), +}); + +// ─── Polling loop (setTimeout-based, no overlap) ─── + +let pollActive = false; +let pollTimer = null; +const POLL_MS = 3000; + +const scheduleNext = () => { + if (!pollActive) return; + pollTimer = setTimeout(async () => { + try { + if (trackedPayments.size > 0) { + await pollOnce(); + } + // Process pending webhook retries + if (pendingRetries.length > 0) { + await processRetries(); + } + } catch (err) { + logger.error('POLLING', 'Unexpected error:', err.message); + } + scheduleNext(); + }, POLL_MS); +}; + +export const startPolling = () => { + if (pollActive) return; + + // Load persisted state + loadTracked(); + loadRetries(); + + pollActive = true; + scheduleNext(); + logger.info('POLLING', 'Started (interval: 3s, persistence: enabled)'); +}; + +export const stopPolling = () => { + pollActive = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + saveTracked(); + saveRetries(); + logger.info('POLLING', 'Stopped'); +}; + +export const getTrackedPayments = () => Object.fromEntries(trackedPayments); diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..075e674 --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,507 @@ +import { Router } from 'express'; +import { DEVICE, APP, UA_NATIVE, ENTRANCE_HEADERS_BASE, KASPI_ENTRANCE_URL, KASPI_MTOKEN_URL } from '../config.js'; +import { createEmptySession, applyOrgContext } from '../session.js'; +import { + generateECDH, + completeECDH, + completeECDHWithSaved, + computeTokenSnMac, + signDataPayload, + computeXSU, + computeXSign, + encryptSecret, + decryptSecret, +} from '../crypto.js'; +import { loggedFetch, extractUserToken, entranceCookie, generateUUID, nowISO } from '../helpers.js'; + +const router = Router(); + +// In-flight auth sessions keyed by processId (temporary, cleared after finish) +const authSessions = new Map(); + +// ═══════════════════════════════════════════════════ +// Step 1 — Init entrance (get processId) +// ═══════════════════════════════════════════════════ + +router.post('/init', async (req, res) => { + const session = createEmptySession(); + + try { + const resp = await loggedFetch(`${KASPI_ENTRANCE_URL}/api/v1/entrance/step`, { + method: 'POST', + headers: { + ...ENTRANCE_HEADERS_BASE, + Referer: `${KASPI_ENTRANCE_URL}/process/entrance/?auth=2&appBuild=${APP.build}&appVersion=${APP.version}&platformVersion=${APP.platformVer}&platformType=IOS&deviceBrand=${APP.brand}&deviceModel=${APP.model}&deviceId=${DEVICE.deviceId}&installId=${DEVICE.installId}&frontCameraAvailable=true&sf=registration&pc=KPEntrance&noPass=0`, + Cookie: entranceCookie(), + }, + body: JSON.stringify({ + data: {}, + Data: { + auth: '2', + appBuild: APP.build, + appVersion: APP.version, + platformVersion: APP.platformVer, + platformType: 'IOS', + deviceBrand: APP.brand, + deviceModel: APP.model, + deviceId: DEVICE.deviceId, + installId: DEVICE.installId, + frontCameraAvailable: 'true', + sf: 'registration', + pc: 'KPEntrance', + noPass: '0', + }, + actType: 'Success', + }), + }); + + const ut = extractUserToken(resp); + if (ut) session.userToken = ut; + + const body = await resp.json(); + if (body.meta?.pId) { + session.processId = body.meta.pId; + authSessions.set(session.processId, session); + } + + res.json({ success: !!session.processId, processId: session.processId, view: body.view?.code, body }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ═══════════════════════════════════════════════════ +// Step 2 — Send phone number (triggers SMS) +// ═══════════════════════════════════════════════════ + +router.post('/send-phone', async (req, res) => { + const { phoneNumber, processId } = req.body; + if (!phoneNumber) return res.status(400).json({ error: 'phoneNumber required (e.g. 7XXXXXXXXX)' }); + if (!processId) return res.status(400).json({ error: 'processId required (from /api/auth/init)' }); + + const session = authSessions.get(processId); + if (!session) return res.status(400).json({ error: 'Unknown processId. Call /api/auth/init first' }); + + session.phoneNumber = phoneNumber; + + try { + const resp = await loggedFetch(`${KASPI_ENTRANCE_URL}/api/v1/entrance/step`, { + method: 'POST', + headers: { + ...ENTRANCE_HEADERS_BASE, + Referer: `${KASPI_ENTRANCE_URL}/process/universal-enter-phone-number?pId=${session.processId}&firstPage=KPUniversalEnterPhoneNumber`, + Cookie: entranceCookie(session.userToken), + }, + body: JSON.stringify({ + meta: { pId: session.processId, sn: 'EnterPhoneNumber' }, + data: { phoneNumber }, + actType: 'Success', + }), + }); + + const ut = extractUserToken(resp); + if (ut) session.userToken = ut; + + const body = await resp.json(); + const smsSent = body.view?.code === 'EnterOtp'; + + res.json({ success: smsSent, processId: session.processId, desc: body.data?.desc, view: body.view?.code, body }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ═══════════════════════════════════════════════════ +// Step 3 — Submit SMS OTP code +// ═══════════════════════════════════════════════════ + +router.post('/verify-otp', async (req, res) => { + const { otp, processId } = req.body; + if (!otp) return res.status(400).json({ error: 'otp required' }); + if (!processId) return res.status(400).json({ error: 'processId required' }); + + const session = authSessions.get(processId); + if (!session) return res.status(400).json({ error: 'Unknown processId' }); + + try { + const resp = await loggedFetch(`${KASPI_ENTRANCE_URL}/api/v1/entrance/step`, { + method: 'POST', + headers: { + ...ENTRANCE_HEADERS_BASE, + Referer: `${KASPI_ENTRANCE_URL}/process/universal-enter-phone-number?pId=${session.processId}&firstPage=KPUniversalEnterPhoneNumber`, + Cookie: entranceCookie(session.userToken), + }, + body: JSON.stringify({ + meta: { pId: session.processId, sn: 'ViewEnterOtp' }, + data: { userOtp: otp, inputType: 'auto' }, + actType: 'Success', + }), + }); + + const ut = extractUserToken(resp); + if (ut) session.userToken = ut; + + const body = await resp.json(); + + if (body.data?.type === 'kpDeviceRegistration' || body.view?.code === 'KPMobileCall') { + // OTP verified — automatically call finish + const finishResult = await doFinish(session); + authSessions.delete(processId); + res.json({ + success: true, + processId: session.processId, + step: 'finished', + message: 'OTP verified and finish completed', + otpBody: body, + ...finishResult, + }); + } else { + res.json({ success: false, processId: session.processId, step: 'otp_response', body }); + } + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ═══════════════════════════════════════════════════ +// Finish logic (shared by verify-otp and /finish) +// ═══════════════════════════════════════════════════ + +async function doFinish(session) { + const ecdhX509 = generateECDH(); + console.log('Generated ECDH public key for guard.x509:', ecdhX509); + + const signedDataObj = { + installId: DEVICE.installId, + time: nowISO(), + auth: [{ value: '', type: 'pincode' }], + userIdHash: '', + }; + const signedDataB64 = Buffer.from(JSON.stringify(signedDataObj)).toString('base64'); + + const finishUrl = `${KASPI_ENTRANCE_URL}/api/v1/kpentrance/finish`; + const finishHeaders = { + 'Content-Type': 'application/json', + Accept: '*/*', + 'Accept-Language': 'ru', + 'Accept-Encoding': 'gzip, deflate, br', + 'User-Agent': UA_NATIVE, + 'X-Time': nowISO(), + 'X-Call': 'notConnected', + 'X-Platform-Type': APP.platform, + 'X-PkTag': DEVICE.pkTag, + 'X-SU': computeXSU(finishUrl), + 'X-Net-Type': 'WIFI/ETHERNET', + 'X-Emulator': '0', + 'X-Locale': APP.locale, + 'X-SV': '2', + 'X-Request-ID': generateUUID(), + 'X-Time-Zone': 'GMT+05:00', + 'X-SH': 'url,X-Time-Zone,X-Request-ID,X-Net-Type,X-Emulator,X-Call,X-Platform-Type,X-Locale,X-Time,X-SV', + }; + finishHeaders['X-Sign'] = computeXSign(finishUrl, finishHeaders, finishHeaders['X-SH']); + + const resp = await loggedFetch(finishUrl, { + method: 'POST', + headers: finishHeaders, + body: JSON.stringify({ + signed: { sign: signDataPayload(signedDataB64), data: signedDataB64 }, + guard: { pinHash: DEVICE.pinHash, x509: ecdhX509 }, + processId: session.processId, + }), + }); + + const body = await resp.json(); + + if (body.success && body.data?.tokenSN) { + session.tokenSN = body.data.tokenSN; + + let vtokenSecret = null; + let rawSecret = null; + if (body.data.x509) { + try { + rawSecret = completeECDH(body.data.x509); + vtokenSecret = encryptSecret(rawSecret); + console.log('vtoken activated successfully'); + } catch (e) { + console.error('ECDH key agreement failed:', e.message); + } + } + + // Fetch org context + const orgUrl = `${KASPI_MTOKEN_URL}/v08/organizations/org-context-otp`; + const piValue = session.profileId != null ? String(session.profileId) : ''; + const orgHeaders = { + 'Content-Type': 'application/json', + Accept: '*/*', + 'Accept-Language': 'ru', + 'Accept-Encoding': 'gzip, deflate, br', + 'User-Agent': UA_NATIVE, + 'X-Kb-TokenSn': session.tokenSN, + 'X-Kb-TokenSnMac': computeTokenSnMac(session.tokenSN, rawSecret), + 'X-Install-ID': DEVICE.installId, + 'X-App-Ver': APP.version, + 'X-App-Bld': APP.build, + 'X-Locale': APP.locale, + 'X-Call': 'notConnected', + 'X-Time': nowISO(), + 'X-S': 'R:0|E:0|RH:0|N:0', + 'X-SV': '2', + 'X-Kb-Client-Ip': '192.168.1.96', + 'X-PkTag': DEVICE.pkTag, + 'X-SU': computeXSU(orgUrl), + 'X-SH': piValue + ? 'url,X-Kb-Client-Ip,X-App-Bld,X-S,X-Kb-TokenSn,X-Time,X-App-Ver,X-Kb-TokenSnMac,X-Call,X-PI,X-Install-ID,X-Locale,X-SV' + : 'url,X-Kb-Client-Ip,X-Time,X-App-Ver,X-SV,X-Locale,X-App-Bld,X-Install-ID,X-Kb-TokenSn,X-S,X-Kb-TokenSnMac,X-Call', + 'X-Request-ID': generateUUID(), + }; + if (piValue) orgHeaders['X-PI'] = piValue; + orgHeaders['X-Sign'] = computeXSign(orgUrl, orgHeaders, orgHeaders['X-SH']); + + const orgResp = await loggedFetch(orgUrl, { + method: 'POST', + headers: orgHeaders, + body: JSON.stringify({ + DeviceInformation: { + SdkVersion: 'AOTP service', + DeviceId: DEVICE.deviceId, + ApplicationId: 'kz.kaspi.business', + ScreenWidth: APP.screenW, + Model: APP.model, + ScreenHeight: APP.screenH, + DeviceName: APP.deviceName, + VersionName: APP.version, + BuildRelease: `${APP.platform} ${APP.platformVer}`, + Brand: APP.brand, + Board: APP.platformVer, + Platform: APP.platform, + Product: 'Kaspi Pay', + frontCameraAvailable: true, + VersionCode: APP.build, + InstallId: DEVICE.installId, + }, + OrganizationId: 0, + }), + }); + + const orgBody = await orgResp.json(); + + if (orgBody.Data?.Current?.ProfileId) { + applyOrgContext(session, orgBody.Data); + } + + return { + tokenSN: session.tokenSN, + vtokenSecret, + profileId: session.profileId, + organizationId: session.organizationId, + orgName: session.orgName, + phone: session.phoneNumber, + organizations: orgBody.Data?.Organizations, + }; + } else { + throw new Error('Finish failed: ' + JSON.stringify(body)); + } +} + +// ═══════════════════════════════════════════════════ +// Refresh — SignInLite (new tokenSN + vtokenSecret) +// POST /v03/auth/sign-in-lite +// ═══════════════════════════════════════════════════ + +router.post('/refresh', async (req, res) => { + const { tokenSN, vtokenSecret, organizationId } = req.body; + if (!tokenSN) return res.status(400).json({ error: 'tokenSN required' }); + if (!vtokenSecret) return res.status(400).json({ error: 'vtokenSecret required' }); + + try { + const rawSecret = decryptSecret(vtokenSecret); + + const liteUrl = `${KASPI_MTOKEN_URL}/v03/auth/sign-in-lite`; + const liteHeaders = { + 'Content-Type': 'application/json', + Accept: '*/*', + 'Accept-Language': 'ru', + 'Accept-Encoding': 'gzip, deflate, br', + 'User-Agent': UA_NATIVE, + 'X-Kb-TokenSn': tokenSN, + 'X-Kb-TokenSnMac': computeTokenSnMac(tokenSN, rawSecret), + 'X-Install-ID': DEVICE.installId, + 'X-App-Ver': APP.version, + 'X-App-Bld': APP.build, + 'X-Locale': APP.locale, + 'X-Call': 'notConnected', + 'X-Time': nowISO(), + 'X-S': 'R:0|E:0|RH:0|N:0', + 'X-SV': '2', + 'X-Kb-Client-Ip': '192.168.1.96', + 'X-PkTag': DEVICE.pkTag, + 'X-SU': computeXSU(liteUrl), + 'X-SH': + 'url,X-Kb-Client-Ip,X-Time,X-App-Ver,X-SV,X-Locale,X-App-Bld,X-Install-ID,X-Kb-TokenSn,X-S,X-Kb-TokenSnMac,X-Call', + 'X-Request-ID': generateUUID(), + }; + liteHeaders['X-Sign'] = computeXSign(liteUrl, liteHeaders, liteHeaders['X-SH']); + + const resp = await loggedFetch(liteUrl, { + method: 'POST', + headers: liteHeaders, + body: JSON.stringify({ + OrganizationId: organizationId || 0, + DeviceInformation: { + SdkVersion: 'AOTP service', + DeviceId: DEVICE.deviceId, + ApplicationId: 'kz.kaspi.business', + ScreenWidth: APP.screenW, + Model: APP.model, + ScreenHeight: APP.screenH, + DeviceName: APP.deviceName, + VersionName: APP.version, + BuildRelease: `${APP.platform} ${APP.platformVer}`, + Brand: APP.brand, + Board: APP.platformVer, + Platform: APP.platform, + Product: 'Kaspi Pay', + frontCameraAvailable: true, + VersionCode: APP.build, + InstallId: DEVICE.installId, + }, + }), + }); + + const body = await resp.json(); + + if (body.StatusCode === 0 && body.Data) { + const newTokenSN = body.Data.TokenSn || body.Data.tokenSN || tokenSN; + let newVtokenSecret = vtokenSecret; + let newRawSecret = null; + const serverX509 = body.Data.X509 || body.Data.x509; + + if (serverX509) { + try { + newRawSecret = completeECDHWithSaved(serverX509); + newVtokenSecret = encryptSecret(newRawSecret); + console.log('SignInLite: new vtoken activated successfully'); + } catch (e) { + console.error('SignInLite ECDH failed:', e.message); + } + } + + const activeRawSecret = newRawSecret || decryptSecret(newVtokenSecret); + + // ── Step 2: org-context-otp to load organization context ── + const session = createEmptySession(); + session.tokenSN = newTokenSN; + let orgContextOk = false; + + // Pre-fill from SignInLite response if available + if (body.Data.OrganizationContext || body.Data.OrganizationContextLite) { + applyOrgContext(session, body.Data.OrganizationContext || body.Data.OrganizationContextLite); + } + + try { + const orgUrl = `${KASPI_MTOKEN_URL}/v08/organizations/org-context-otp`; + const orgHeaders = { + 'Content-Type': 'application/json', + Accept: '*/*', + 'Accept-Language': 'ru', + 'Accept-Encoding': 'gzip, deflate, br', + 'User-Agent': UA_NATIVE, + 'X-Kb-TokenSn': newTokenSN, + 'X-Kb-TokenSnMac': computeTokenSnMac(newTokenSN, activeRawSecret), + 'X-Install-ID': DEVICE.installId, + 'X-App-Ver': APP.version, + 'X-App-Bld': APP.build, + 'X-Locale': APP.locale, + 'X-Call': 'notConnected', + 'X-Time': nowISO(), + 'X-S': 'R:0|E:0|RH:0|N:0', + 'X-SV': '2', + 'X-Kb-Client-Ip': '192.168.1.96', + 'X-PkTag': DEVICE.pkTag, + 'X-PI': session.profileId || '', + 'X-SU': computeXSU(orgUrl), + 'X-SH': + 'url,X-Kb-Client-Ip,X-Time,X-App-Ver,X-SV,X-Locale,X-App-Bld,X-Install-ID,X-Kb-TokenSn,X-S,X-Kb-TokenSnMac,X-Call', + 'X-Request-ID': generateUUID(), + }; + orgHeaders['X-Sign'] = computeXSign(orgUrl, orgHeaders, orgHeaders['X-SH']); + + const orgResp = await loggedFetch(orgUrl, { + method: 'POST', + headers: orgHeaders, + body: JSON.stringify({ + OrganizationId: organizationId || session.organizationId || 0, + DeviceInformation: { + SdkVersion: 'AOTP service', + DeviceId: DEVICE.deviceId, + ApplicationId: 'kz.kaspi.business', + ScreenWidth: APP.screenW, + Model: APP.model, + ScreenHeight: APP.screenH, + DeviceName: APP.deviceName, + VersionName: APP.version, + BuildRelease: `${APP.platform} ${APP.platformVer}`, + Brand: APP.brand, + Board: APP.platformVer, + Platform: APP.platform, + Product: 'Kaspi Pay', + frontCameraAvailable: true, + VersionCode: APP.build, + InstallId: DEVICE.installId, + }, + }), + }); + + const orgBody = await orgResp.json(); + if (orgBody.StatusCode === 0 && orgBody.Data) { + applyOrgContext(session, orgBody.Data); + orgContextOk = true; + console.log('Refresh org-context-otp: OK, profileId:', session.profileId, 'orgId:', session.organizationId); + } else { + console.log('Refresh org-context-otp: failed (', orgBody.StatusCode, ')'); + } + } catch (e) { + console.error('Refresh org-context-otp error:', e.message); + } + + res.json({ + success: true, + tokenSN: newTokenSN, + vtokenSecret: newVtokenSecret, + profileId: session.profileId, + organizationId: session.organizationId, + orgName: session.orgName, + sessionId: body.Data.SessionId, + organizations: body.Data.OrganizationContext?.Organizations || body.Data.OrganizationContextLite?.Organizations, + orgContext: orgContextOk, + message: 'Session refreshed via SignInLite + org-context', + }); + } else { + res.json({ + success: false, + statusCode: body.StatusCode, + message: + body.Message || body.Description || 'SignInLite failed — token may be expired, re-auth via SMS required', + body, + }); + } + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Session status (client sends tokenSN) ─── + +router.post('/session', (req, res) => { + const { tokenSN } = req.body || {}; + res.json({ authenticated: !!tokenSN, tokenSN }); +}); + +// ─── Logout ─── + +router.post('/logout', (req, res) => { + res.json({ success: true }); +}); + +export default router; diff --git a/src/routes/history.js b/src/routes/history.js new file mode 100644 index 0000000..42dce66 --- /dev/null +++ b/src/routes/history.js @@ -0,0 +1,75 @@ +import { Router } from 'express'; +import { KASPI_QRPAY_URL } from '../config.js'; +import { loggedFetch, signedQrPayHeaders } from '../helpers.js'; +import { decryptSecret } from '../crypto.js'; + +const router = Router(); + +// Extract session from request headers +const extractSession = (req) => ({ + tokenSN: req.headers['x-token-sn'] || null, + profileId: req.headers['x-profile-id'] || null, + vtokenSecret: req.headers['x-vtoken-secret'] || null, +}); + +const requireAuth = (req, res, next) => { + const session = extractSession(req); + if (!session.tokenSN) return res.status(401).json({ error: 'Missing X-Token-SN header.' }); + if (!session.vtokenSecret) return res.status(401).json({ error: 'Missing X-Vtoken-Secret header.' }); + try { + session.decryptedSecret = decryptSecret(session.vtokenSecret); + } catch { + return res.status(401).json({ error: 'Invalid or expired vtokenSecret. Re-authenticate.' }); + } + req.session = session; + next(); +}; + +router.use(requireAuth); + +// ─── Operations history (QR + remote) ─── + +router.post('/operations', async (req, res) => { + const { endDate, lastTransactionDate, statementPeriodCode } = req.body; + if (!endDate) return res.status(400).json({ error: 'endDate required' }); + try { + const url = `${KASPI_QRPAY_URL}/v02/history/operations`; + const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' }; + const resp = await loggedFetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + EndDate: endDate, + LastTransactionDate: lastTransactionDate || '', + StatementPeriodCode: statementPeriodCode ?? 0, + }), + }); + res.json(await resp.json()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Operation details ─── + +router.post('/details', async (req, res) => { + const { id, operationMethod } = req.body; + if (!id) return res.status(400).json({ error: 'id required' }); + try { + const url = `${KASPI_QRPAY_URL}/v01/kaspi-qr/operations/details`; + const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' }; + const resp = await loggedFetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + Id: Number(id), + OperationMethod: operationMethod ?? 0, + }), + }); + res.json(await resp.json()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/src/routes/invoice.js b/src/routes/invoice.js new file mode 100644 index 0000000..84e7cf5 --- /dev/null +++ b/src/routes/invoice.js @@ -0,0 +1,137 @@ +import { Router } from 'express'; +import { KASPI_QRPAY_URL } from '../config.js'; +import { loggedFetch, signedQrPayHeaders } from '../helpers.js'; +import { decryptSecret } from '../crypto.js'; +import { trackPayment } from '../polling.js'; + +const router = Router(); + +// Extract session from request headers +const extractSession = (req) => ({ + tokenSN: req.headers['x-token-sn'] || null, + profileId: req.headers['x-profile-id'] || null, + vtokenSecret: req.headers['x-vtoken-secret'] || null, +}); + +const requireAuth = (req, res, next) => { + const session = extractSession(req); + if (!session.tokenSN) return res.status(401).json({ error: 'Missing X-Token-SN header.' }); + if (!session.vtokenSecret) return res.status(401).json({ error: 'Missing X-Vtoken-Secret header.' }); + try { + session.decryptedSecret = decryptSecret(session.vtokenSecret); + } catch { + return res.status(401).json({ error: 'Invalid or expired vtokenSecret. Re-authenticate.' }); + } + req.session = session; + next(); +}; + +router.use(requireAuth); + +// ─── Client info ─── + +router.get('/client-info', async (req, res) => { + const { phoneNumber } = req.query; + if (!phoneNumber) return res.status(400).json({ error: 'phoneNumber required' }); + + try { + const url = `${KASPI_QRPAY_URL}/v01/remote/client-info?phoneNumber=${phoneNumber}`; + const resp = await loggedFetch(url, { headers: signedQrPayHeaders(url, req.session) }); + res.json(await resp.json()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Create invoice ─── + +router.post('/create', async (req, res) => { + const { phoneNumber, amount, comment } = req.body; + if (!phoneNumber || !amount) return res.status(400).json({ error: 'phoneNumber and amount required' }); + + try { + const url = `${KASPI_QRPAY_URL}/v01/remote/create`; + const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' }; + const resp = await loggedFetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ PhoneNumber: phoneNumber, Amount: Number(amount), Comment: comment || '' }), + }); + const kaspiResponse = await resp.json(); + const d = kaspiResponse.Data; + if (d && d.Id && d.Status === 'RemotePaymentCreated') { + trackPayment( + d.Id, + 'invoice', + { + tokenSN: req.session.tokenSN, + vtokenSecret: req.headers['x-vtoken-secret'], + profileId: req.session.profileId, + }, + { + amount: d.Amount, + clientMobile: d.ClientMobile, + receiptUrl: d.ReceiptUrl, + orderNumber: d.OrderNumber, + }, + ); + } + res.json(kaspiResponse); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Invoice details ─── + +router.get('/details', async (req, res) => { + const { operationId } = req.query; + if (!operationId) return res.status(400).json({ error: 'operationId required' }); + + try { + const url = `${KASPI_QRPAY_URL}/v02/remote/details?operationId=${operationId}`; + const resp = await loggedFetch(url, { headers: signedQrPayHeaders(url, req.session) }); + res.json(await resp.json()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Cancel invoice ─── + +router.post('/cancel', async (req, res) => { + const { operationId } = req.body; + if (!operationId) return res.status(400).json({ error: 'operationId required' }); + + try { + const url = `${KASPI_QRPAY_URL}/v01/remote/cancel`; + const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' }; + const resp = await loggedFetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ qrOperationId: Number(operationId) }), + }); + res.json(await resp.json()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Invoice history ─── + +router.post('/history', async (req, res) => { + try { + const url = `${KASPI_QRPAY_URL}/v01/remote/history`; + const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' }; + const resp = await loggedFetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ MaxResult: 20 }), + }); + res.json(await resp.json()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/src/routes/qr.js b/src/routes/qr.js new file mode 100644 index 0000000..fc163cb --- /dev/null +++ b/src/routes/qr.js @@ -0,0 +1,100 @@ +import { Router } from 'express'; +import { KASPI_QRPAY_URL } from '../config.js'; +import { loggedFetch, signedQrPayHeaders } from '../helpers.js'; +import { decryptSecret } from '../crypto.js'; +import { trackPayment } from '../polling.js'; + +const router = Router(); + +// Extract session from request headers +const extractSession = (req) => ({ + tokenSN: req.headers['x-token-sn'] || null, + profileId: req.headers['x-profile-id'] || null, + vtokenSecret: req.headers['x-vtoken-secret'] || null, +}); + +const requireAuth = (req, res, next) => { + const session = extractSession(req); + if (!session.tokenSN) return res.status(401).json({ error: 'Missing X-Token-SN header.' }); + if (!session.vtokenSecret) return res.status(401).json({ error: 'Missing X-Vtoken-Secret header.' }); + try { + session.decryptedSecret = decryptSecret(session.vtokenSecret); + } catch { + return res.status(401).json({ error: 'Invalid or expired vtokenSecret. Re-authenticate.' }); + } + req.session = session; + next(); +}; + +router.use(requireAuth); + +// ─── Create QR token ─── + +router.post('/create', async (req, res) => { + const { amount, latitude, longitude } = req.body; + if (!amount) return res.status(400).json({ error: 'amount required' }); + + try { + const url = `${KASPI_QRPAY_URL}/v01/qr-token/create`; + const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' }; + const resp = await loggedFetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + PaymentAmount: Number(amount), + DeviceInterface: 'Pos', + Latitude: latitude || 43.204643483375889, + Longitude: longitude || 76.891962364115912, + }), + }); + const kaspiResponse = await resp.json(); + const d = kaspiResponse.Data; + if (d && d.QrOperationId) { + const opts = d.QrPaymentBehaviorOptions || {}; + trackPayment( + d.QrOperationId, + 'qr', + { + tokenSN: req.session.tokenSN, + vtokenSecret: req.headers['x-vtoken-secret'], + profileId: req.session.profileId, + }, + { + qrToken: d.QrToken, + expireDate: d.ExpireDate, + receiptUrl: d.ReceiptUrl, + amount: d.Amount, + pollingIntervals: { + scanWaitTimeout: Number(opts.qrCodeScanWaitTimeout) || 180, + scanPollingInterval: Number(opts.qrCodeScanEventPollingInterval) || 3, + statusCountdown: Number(opts.paymentStatusCountdown) || 2, + confirmationTimeout: Number(opts.paymentConfirmationTimeout) || 65, + }, + }, + ); + } + if (d && d.QrToken) { + d.QrToken = d.QrToken.replace('https://qr.kaspi.kz/', 'https://pay.kaspi.kz/pay/'); + } + res.json(kaspiResponse); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── QR payment status ─── + +router.get('/status', async (req, res) => { + const { qrOperationId } = req.query; + if (!qrOperationId) return res.status(400).json({ error: 'qrOperationId required' }); + + try { + const url = `${KASPI_QRPAY_URL}/v02/kaspi-qr/status?qrOperationId=${qrOperationId}`; + const resp = await loggedFetch(url, { headers: signedQrPayHeaders(url, req.session) }); + res.json(await resp.json()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/src/routes/refund.js b/src/routes/refund.js new file mode 100644 index 0000000..f53857f --- /dev/null +++ b/src/routes/refund.js @@ -0,0 +1,54 @@ +import { Router } from 'express'; +import { KASPI_QRPAY_URL } from '../config.js'; +import { loggedFetch, signedQrPayHeaders } from '../helpers.js'; +import { decryptSecret } from '../crypto.js'; + +const router = Router(); + +// Extract session from request headers +const extractSession = (req) => ({ + tokenSN: req.headers['x-token-sn'] || null, + profileId: req.headers['x-profile-id'] || null, + vtokenSecret: req.headers['x-vtoken-secret'] || null, +}); + +const requireAuth = (req, res, next) => { + const session = extractSession(req); + if (!session.tokenSN) return res.status(401).json({ error: 'Missing X-Token-SN header.' }); + if (!session.vtokenSecret) return res.status(401).json({ error: 'Missing X-Vtoken-Secret header.' }); + try { + session.decryptedSecret = decryptSecret(session.vtokenSecret); + } catch { + return res.status(401).json({ error: 'Invalid or expired vtokenSecret. Re-authenticate.' }); + } + req.session = session; + next(); +}; + +router.use(requireAuth); + +// ─── Return (refund) ─── + +router.post('/create', async (req, res) => { + const { qrOperationId, returnAmount } = req.body; + if (!qrOperationId || !returnAmount) + return res.status(400).json({ error: 'qrOperationId and returnAmount required' }); + try { + const url = `${KASPI_QRPAY_URL}/v01/kaspi-qr/history-pos-return`; + const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' }; + const resp = await loggedFetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + ReturnAmount: Number(returnAmount), + QrOperationId: Number(qrOperationId), + DeviceInterface: 'Pos', + }), + }); + res.json(await resp.json()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/src/routes/session.js b/src/routes/session.js new file mode 100644 index 0000000..db64371 --- /dev/null +++ b/src/routes/session.js @@ -0,0 +1,63 @@ +import { Router } from 'express'; +import { KASPI_QRPAY_URL } from '../config.js'; +import { loggedFetch, signedQrPayHeaders } from '../helpers.js'; +import { decryptSecret } from '../crypto.js'; + +const router = Router(); + +// Extract session from request headers +const extractSession = (req) => ({ + tokenSN: req.headers['x-token-sn'] || null, + profileId: req.headers['x-profile-id'] || null, + vtokenSecret: req.headers['x-vtoken-secret'] || null, +}); + +// ─── Check session validity ─── + +router.get('/check', async (req, res) => { + const session = extractSession(req); + + // 1. Check required headers + if (!session.tokenSN) return res.status(401).json({ active: false, error: 'Missing X-Token-SN header.' }); + if (!session.vtokenSecret) return res.status(401).json({ active: false, error: 'Missing X-Vtoken-Secret header.' }); + + // 2. Try to decrypt vtokenSecret + try { + session.decryptedSecret = decryptSecret(session.vtokenSecret); + } catch { + return res.status(401).json({ active: false, error: 'Invalid or expired vtokenSecret. Re-authenticate.' }); + } + + // 3. Ping Kaspi API to verify the token is still accepted + try { + const url = `${KASPI_QRPAY_URL}/v02/history/operations`; + const headers = { ...signedQrPayHeaders(url, session), 'Content-Type': 'application/json' }; + const resp = await loggedFetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + EndDate: new Date().toISOString().slice(0, 10), + LastTransactionDate: '', + StatementPeriodCode: 0, + }), + }); + + const body = await resp.json().catch(() => ({})); + + // Kaspi may return HTTP 200 but with error StatusCode in body + if (resp.ok && (!body.StatusCode || body.StatusCode === 0)) { + return res.json({ active: true }); + } + + return res.status(resp.ok ? 401 : resp.status).json({ + active: false, + error: body.Message || body.message || 'Session rejected by Kaspi API.', + code: body.StatusCode || body.Code, + details: body, + }); + } catch (err) { + return res.status(500).json({ active: false, error: err.message }); + } +}); + +export default router; diff --git a/src/session.js b/src/session.js new file mode 100644 index 0000000..3c9bef4 --- /dev/null +++ b/src/session.js @@ -0,0 +1,43 @@ +const SESSION_FIELDS = [ + 'processId', + 'userToken', + 'tokenSN', + 'tokenSnMac', + 'qrPayTokenSnMac', + 'profileId', + 'phoneNumber', + 'orgName', + 'userId', + 'organizationId', + 'organizationIdn', + 'organizationKbe', + 'empId', + 'accessLevelType', + 'isCashier', + 'payerType', + 'categoryName', + 'possiblePaymentMethods', + 'showFakeCard', +]; + +export const createEmptySession = () => Object.fromEntries(SESSION_FIELDS.map((k) => [k, null])); + +export const sessionFields = () => [...SESSION_FIELDS]; + +export const applyOrgContext = (session, data) => { + const cur = data.Current || {}; + session.profileId = cur.ProfileId || session.profileId; + session.orgName = cur.OrganizationName || session.orgName; + session.organizationId = cur.OrganizationId || session.organizationId; + session.organizationIdn = cur.OrganizationIdn || session.organizationIdn; + session.organizationKbe = cur.OrganizationKbe || session.organizationKbe; + session.empId = cur.EmpId || session.empId; + session.accessLevelType = cur.AccessLevelType ?? session.accessLevelType; + session.isCashier = cur.IsCashier ?? session.isCashier; + session.payerType = cur.PayerType || session.payerType; + session.categoryName = cur.CategoryName || session.categoryName; + session.possiblePaymentMethods = cur.PossiblePaymentMethods || session.possiblePaymentMethods; + session.showFakeCard = cur.ShowFakeCard ?? session.showFakeCard; + session.userId = data.UserId || session.userId; + session.phoneNumber = data.PhoneNumber || session.phoneNumber; +}; diff --git a/src/webhookStore.js b/src/webhookStore.js new file mode 100644 index 0000000..48aa3bc --- /dev/null +++ b/src/webhookStore.js @@ -0,0 +1,31 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const WEBHOOKS_FILE = path.join(__dirname, '..', 'webhooks.json'); + +/** + * Читает webhooks.json и возвращает массив вебхуков. + * При ошибке чтения/парсинга возвращает []. + */ +export const loadWebhooks = () => { + try { + const raw = fs.readFileSync(WEBHOOKS_FILE, 'utf8'); + const hooks = JSON.parse(raw); + if (!Array.isArray(hooks)) return []; + return hooks; + } catch (err) { + if (err.code !== 'ENOENT') { + console.error('[WEBHOOK STORE] Error reading webhooks.json:', err.message); + } + return []; + } +}; + +/** + * Возвращает вебхуки, подписанные на указанное событие. + */ +export const getWebhooksByEvent = (event) => { + return loadWebhooks().filter((hook) => hook.url && Array.isArray(hook.events) && hook.events.includes(event)); +}; diff --git a/test/crypto.test.js b/test/crypto.test.js new file mode 100644 index 0000000..7aa8a5b --- /dev/null +++ b/test/crypto.test.js @@ -0,0 +1,56 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// Set TOKEN_SECRET_KEY before importing crypto module +process.env.TOKEN_SECRET_KEY = 'a'.repeat(64); + +const { encryptSecret, decryptSecret, computeTokenSnMac, computeXSU } = await import('../src/crypto.js'); + +describe('encryptSecret / decryptSecret', () => { + it('should round-trip a secret buffer', () => { + const original = Buffer.from('my-super-secret-value'); + const encrypted = encryptSecret(original); + const decrypted = decryptSecret(encrypted); + assert.deepStrictEqual(decrypted, original); + }); + + it('should produce different ciphertexts for the same input (random IV)', () => { + const original = Buffer.from('test'); + const a = encryptSecret(original); + const b = encryptSecret(original); + assert.notEqual(a, b); + }); + + it('should fail to decrypt tampered data', () => { + const encrypted = encryptSecret(Buffer.from('secret')); + const buf = Buffer.from(encrypted, 'base64'); + buf[20] ^= 0xff; // tamper with ciphertext + assert.throws(() => decryptSecret(buf.toString('base64'))); + }); +}); + +describe('computeTokenSnMac', () => { + it('should return 6-digit string', () => { + const secret = Buffer.from('0123456789abcdef0123456789abcdef', 'hex'); + const result = computeTokenSnMac('TSN12345', secret); + assert.match(result, /^\d{6}$/); + }); + + it('should return 000000 when secret is null', () => { + const result = computeTokenSnMac('TSN12345', null); + assert.equal(result, '000000'); + }); +}); + +describe('computeXSU', () => { + it('should return md5 hex of lowercased url', () => { + const result = computeXSU('https://example.com/Path'); + assert.match(result, /^[0-9a-f]{32}$/); + }); + + it('should be case-insensitive', () => { + const a = computeXSU('HTTPS://EXAMPLE.COM'); + const b = computeXSU('https://example.com'); + assert.equal(a, b); + }); +}); diff --git a/test/helpers.test.js b/test/helpers.test.js new file mode 100644 index 0000000..451b011 --- /dev/null +++ b/test/helpers.test.js @@ -0,0 +1,80 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// Set TOKEN_SECRET_KEY before importing (helpers imports from crypto via config chain) +process.env.TOKEN_SECRET_KEY = 'a'.repeat(64); + +const { generateUUID, nowISO, entranceCookie, extractUserToken } = await import('../src/helpers.js'); + +describe('generateUUID', () => { + it('should return an uppercase UUID', () => { + const uuid = generateUUID(); + assert.match(uuid, /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/); + }); + + it('should return unique values', () => { + const a = generateUUID(); + const b = generateUUID(); + assert.notEqual(a, b); + }); +}); + +describe('nowISO', () => { + it('should return ISO-like string with timezone offset', () => { + const result = nowISO(); + // Should match pattern like 2025-05-09T12:00:00.000+0600 + assert.match(result, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{4}$/); + }); +}); + +describe('entranceCookie', () => { + it('should contain deviceId and installId', () => { + const cookie = entranceCookie(); + assert.ok(cookie.includes('deviceId=')); + assert.ok(cookie.includes('installId=')); + assert.ok(cookie.includes('is_mobile_app=true')); + }); + + it('should include user_token when provided', () => { + const cookie = entranceCookie('my-token'); + assert.ok(cookie.includes('user_token=my-token')); + }); + + it('should not include user_token when not provided', () => { + const cookie = entranceCookie(); + assert.ok(!cookie.includes('user_token=')); + }); +}); + +describe('extractUserToken', () => { + it('should extract user_token from set-cookie headers', () => { + const fakeResp = { + headers: { + raw: () => ({ + 'set-cookie': ['user_token=abc123; Path=/; HttpOnly'], + }), + }, + }; + assert.equal(extractUserToken(fakeResp), 'abc123'); + }); + + it('should return null when no user_token cookie', () => { + const fakeResp = { + headers: { + raw: () => ({ + 'set-cookie': ['other=value; Path=/'], + }), + }, + }; + assert.equal(extractUserToken(fakeResp), null); + }); + + it('should return null when no set-cookie header', () => { + const fakeResp = { + headers: { + raw: () => ({}), + }, + }; + assert.equal(extractUserToken(fakeResp), null); + }); +}); diff --git a/test/session.test.js b/test/session.test.js new file mode 100644 index 0000000..4eabcc3 --- /dev/null +++ b/test/session.test.js @@ -0,0 +1,68 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createEmptySession, sessionFields, applyOrgContext } from '../src/session.js'; + +describe('createEmptySession', () => { + it('should return an object with all fields set to null', () => { + const session = createEmptySession(); + assert.equal(typeof session, 'object'); + for (const key of sessionFields()) { + assert.equal(session[key], null, `${key} should be null`); + } + }); + + it('should return a new object each time', () => { + const a = createEmptySession(); + const b = createEmptySession(); + assert.notEqual(a, b); + }); +}); + +describe('sessionFields', () => { + it('should return an array of strings', () => { + const fields = sessionFields(); + assert.ok(Array.isArray(fields)); + assert.ok(fields.length > 0); + for (const f of fields) { + assert.equal(typeof f, 'string'); + } + }); + + it('should include critical fields', () => { + const fields = sessionFields(); + assert.ok(fields.includes('processId')); + assert.ok(fields.includes('tokenSN')); + assert.ok(fields.includes('profileId')); + assert.ok(fields.includes('phoneNumber')); + }); +}); + +describe('applyOrgContext', () => { + it('should populate session from Current data', () => { + const session = createEmptySession(); + const data = { + UserId: 'U123', + PhoneNumber: '+77001234567', + Current: { + ProfileId: 42, + OrganizationName: 'Test Org', + OrganizationId: 'ORG1', + EmpId: 'E1', + IsCashier: true, + }, + }; + applyOrgContext(session, data); + assert.equal(session.userId, 'U123'); + assert.equal(session.phoneNumber, '+77001234567'); + assert.equal(session.profileId, 42); + assert.equal(session.orgName, 'Test Org'); + assert.equal(session.isCashier, true); + }); + + it('should not overwrite existing values with falsy data', () => { + const session = createEmptySession(); + session.profileId = 99; + applyOrgContext(session, { Current: {} }); + assert.equal(session.profileId, 99); + }); +}); diff --git a/webhooks.example.json b/webhooks.example.json new file mode 100644 index 0000000..d356132 --- /dev/null +++ b/webhooks.example.json @@ -0,0 +1,7 @@ +[ + { + "url": "https://example.com/webhook", + "events": ["payment.success", "payment.failed", "payment.expired"], + "secret": "your-webhook-secret" + } +]