commit b676ac6288012370d4007289bf7004371092be4f Author: Yaser Date: Thu Aug 13 20:18:11 2026 +0330 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b07b93d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.env +.git +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..90038ac --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +PORT=8082 +CORS_ORIGIN=http://localhost:3000,http://127.0.0.1:3000 + +# Local dev only — skip 2FA for API keys (requires BARONG_SKIP_API_KEY_2FA=true on Dalan) +SKIP_API_KEY_2FA=true + +# Host dev (BFF on host → Traefik/Envoy) +DALAN_URL=http://www.app.local/api/v2/dalan +DENA_URL=http://www.app.local/api/v2/dena + +# Docker stack (BFF container → Envoy gateway) +# DALAN_URL=http://gateway:8099/api/v2/dalan +# DENA_URL=http://gateway:8099/api/v2/dena +# FIBITEX_HOST=www.app.local +# CORS_ORIGIN=http://shahoo.app.local,http://localhost:3000,http://127.0.0.1:3000 + +# Rango private upstream for /api/1/ranger/ws proxy (WS4) +# Prefer JWT → direct Rango (avoids Envoy cookie 401 on WS upgrade): +# BARONG_URL=http://barong:8001 +# RANGER_PRIVATE_DIRECT_URL=ws://rango:8080/api/v2/ranger/private +# Fallback cookie via gateway: +# RANGER_PRIVATE_URL=ws://gateway:8099/api/v2/ranger/private +# FIBITEX_HOST=www.app.local +# REDIS_URL=redis://redis:6379/1 +# Optional Finex balances stream: +# RANGER_INCLUDE_BALANCES=true +# RANGER_PRIVATE_STREAMS=order,trade,deposit_address diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d98e55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +.env.local +package-lock.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ebfa4ad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1.4 +FROM node:18-alpine + +WORKDIR /app + +RUN apk add --no-cache tini wget + +COPY package.json yarn.lock ./ + +ENV NODE_ENV=production + +RUN yarn install --frozen-lockfile --production --non-interactive + +COPY src ./src +COPY data ./data + +EXPOSE 8082 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8082/health || exit 1 + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ae9c61 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Shahoo BFF + +Adapter between **Shahoo** (`/api/1/*`) and **Fibitex** (Dalan + Dena). + +## Fibitex docs + +| Doc | Topic | +|-----|--------| +| [`Docs/Fibitex-staging-deploy.md`](../Docs/Fibitex-staging-deploy.md) | Staging deploy | +| [`Shahoo/README.md`](../Shahoo/README.md) | Frontend | +| [`Docs/README.md`](../Docs/README.md) | Central Fibitex docs | + +## Quick start + +```powershell +cd Shahoo-BFF +yarn install +yarn start +# → http://localhost:8082 +``` + +Copy `.env.example` → `.env` if needed. Requires Fibitex local stack (`www.app.local`). + +## Shahoo config + +In `Shahoo/.env.development`: + +```env +REACT_APP_API_URL=http://localhost:8082 +``` + +Docker (Traefik): same-origin — `REACT_APP_API_URL=` (empty) at build time. + +## Test login (Fibitex local) + +| Email | Password | Notes | +|-------|----------|-------| +| `user@test.com` | `123qwe!@#QWE` | member, KYC 0 | +| `bot@test.com` | `123qwe!@#QWE` | bot group, KYC full | +| `admin@test.com` | `123qwe!@#QWE` | superadmin | + +## E2E smoke test + +From monorepo root: + +```powershell +.\scripts\test-shahoo-e2e.ps1 +``` + +## Implemented routes (summary) + +| Area | Routes | +|------|--------| +| Health | `GET /health`, `GET /api/1/health` | +| Auth | authenticate, logout, register, forgot | +| User | currentUserSummary, profile, referrer, limitations, login/info, device/info, 2FA, change password, setting | +| Wallets | self, balance, currencies, address, update/favorite, totalBalances | +| Market | orders, orderBook, ohlcvs, trades, doneOrders, constants | +| Fiat/crypto | deposits, withdrawals, irtpays, bank, destinationWallets | +| KYC | kycUsers, otp, jibit, locations, tts | +| Extras | notifications, alerts, market/favorite, transactions, assetHistory | + +Unimplemented `/api/1/*` → `501`. + +## Architecture + +``` +Shahoo :3000 → BFF :8082 → gateway:8099/api/v2/dalan|dena +``` + +Session: BFF UUID token; Dalan cookies + CSRF stored server-side. + +## Docker + +```powershell +scripts\build-docker-apps.bat # custom/shahoo-bff:1 +``` + +Traefik: `shahoo./api/1` → BFF, `shahoo./` → Shahoo SPA. + +## Git + +Separate repo on branch `Update2026` (see `Docs/agents/mandatory-rules.md`). diff --git a/data/dena-swagger.json b/data/dena-swagger.json new file mode 100644 index 0000000..d4cbcf7 --- /dev/null +++ b/data/dena-swagger.json @@ -0,0 +1,1701 @@ +{ + "info": { + "title": "Dena User API v2", + "description": "API for Dena application.", + "contact": { + "name": "Fibitex", + "email": "info@fibitex.com", + "url": "https://fibitex.com" + }, + "version": "2.3.12" + }, + "swagger": "2.0", + "produces": [ + "application/json" + ], + "securityDefinitions": { + "Bearer": { + "type": "apiKey", + "name": "JWT", + "in": "header" + } + }, + "host": "localhost:3000", + "basePath": "/api/v2", + "tags": [ + { + "name": "public", + "description": "Operations about publics" + }, + { + "name": "account", + "description": "Operations about accounts" + }, + { + "name": "market", + "description": "Operations about markets" + } + ], + "paths": { + "/public/health/ready": { + "get": { + "description": "Get application readiness status", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Get application readiness status" + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicHealthReady" + } + }, + "/public/health/alive": { + "get": { + "description": "Get application liveness status", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Get application liveness status" + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicHealthAlive" + } + }, + "/public/version": { + "get": { + "description": "Get running Dena version and build details.", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Get running Dena version and build details." + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicVersion" + } + }, + "/public/timestamp": { + "get": { + "description": "Get server current time, in seconds since Unix epoch.", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Get server current time, in seconds since Unix epoch." + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicTimestamp" + } + }, + "/public/member-levels": { + "get": { + "description": "Returns hash of minimum levels and the privileges they provide.", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Returns hash of minimum levels and the privileges they provide." + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicMemberLevels" + } + }, + "/public/markets/{market}/tickers": { + "get": { + "description": "Get ticker of specific market.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "path", + "name": "market", + "description": "", + "type": "string", + "enum": [ + "btcusd", + "ethbtc", + "ethusd", + "trstbtc", + "trsteth", + "trstusd" + ], + "required": true + } + ], + "responses": { + "200": { + "description": "Get ticker of specific market." + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicMarketsMarketTickers" + } + }, + "/public/markets/tickers": { + "get": { + "description": "Get ticker of all markets.", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Get ticker of all markets." + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicMarketsTickers" + } + }, + "/public/markets/{market}/k-line": { + "get": { + "description": "Get OHLC(k line) of specific market.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "path", + "name": "market", + "description": "", + "type": "string", + "enum": [ + "btcusd", + "ethbtc", + "ethusd", + "trstbtc", + "trsteth", + "trstusd" + ], + "required": true + }, + { + "in": "query", + "name": "period", + "description": "Time period of K line, default to 1. You can choose between 1, 5, 15, 30, 60, 120, 240, 360, 720, 1440, 4320, 10080", + "type": "integer", + "format": "int32", + "default": 1, + "enum": [ + 1, + 5, + 15, + 30, + 60, + 120, + 240, + 360, + 720, + 1440, + 4320, + 10080 + ], + "required": false + }, + { + "in": "query", + "name": "time_from", + "description": "An integer represents the seconds elapsed since Unix epoch. If set, only k-line data after that time will be returned.", + "type": "integer", + "format": "int32", + "required": false + }, + { + "in": "query", + "name": "time_to", + "description": "An integer represents the seconds elapsed since Unix epoch. If set, only k-line data till that time will be returned.", + "type": "integer", + "format": "int32", + "required": false + }, + { + "in": "query", + "name": "limit", + "description": "Limit the number of returned data points default to 30. Ignored if time_from and time_to are given.", + "type": "integer", + "format": "int32", + "default": 30, + "minimum": 1, + "maximum": 10000, + "required": false + } + ], + "responses": { + "200": { + "description": "Get OHLC(k line) of specific market." + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicMarketsMarketKLine" + } + }, + "/public/markets/{market}/depth": { + "get": { + "description": "Get depth or specified market. Both asks and bids are sorted from highest price to lowest.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "path", + "name": "market", + "description": "", + "type": "string", + "enum": [ + "btcusd", + "ethbtc", + "ethusd", + "trstbtc", + "trsteth", + "trstusd" + ], + "required": true + }, + { + "in": "query", + "name": "limit", + "description": "Limit the number of returned price levels. Default to 300.", + "type": "integer", + "format": "int32", + "default": 300, + "minimum": 1, + "maximum": 1000, + "required": false + } + ], + "responses": { + "200": { + "description": "Get depth or specified market. Both asks and bids are sorted from highest price to lowest." + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicMarketsMarketDepth" + } + }, + "/public/markets/{market}/trades": { + "get": { + "description": "Get recent trades on market, each trade is included only once. Trades are sorted in reverse creation order.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "path", + "name": "market", + "description": "", + "type": "string", + "enum": [ + "btcusd", + "ethbtc", + "ethusd", + "trstbtc", + "trsteth", + "trstusd" + ], + "required": true + }, + { + "in": "query", + "name": "limit", + "description": "Limit the number of returned trades. Default to 100.", + "type": "integer", + "format": "int32", + "default": 100, + "minimum": 1, + "maximum": 1000, + "required": false + }, + { + "in": "query", + "name": "page", + "description": "Specify the page of paginated results.", + "type": "integer", + "format": "int32", + "default": 1, + "required": false + }, + { + "in": "query", + "name": "timestamp", + "description": "An integer represents the seconds elapsed since Unix epoch.If set, only trades executed before the time will be returned.", + "type": "integer", + "format": "int32", + "required": false + }, + { + "in": "query", + "name": "order_by", + "description": "If set, returned trades will be sorted in specific order, default to 'desc'.", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + } + ], + "responses": { + "200": { + "description": "Get recent trades on market, each trade is included only once. Trades are sorted in reverse creation order.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Trade" + } + } + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicMarketsMarketTrades" + } + }, + "/public/markets/{market}/order-book": { + "get": { + "description": "Get the order book of specified market.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "path", + "name": "market", + "description": "", + "type": "string", + "enum": [ + "btcusd", + "ethbtc", + "ethusd", + "trstbtc", + "trsteth", + "trstusd" + ], + "required": true + }, + { + "in": "query", + "name": "asks_limit", + "description": "Limit the number of returned sell orders. Default to 20.", + "type": "integer", + "format": "int32", + "default": 20, + "minimum": 1, + "maximum": 200, + "required": false + }, + { + "in": "query", + "name": "bids_limit", + "description": "Limit the number of returned buy orders. Default to 20.", + "type": "integer", + "format": "int32", + "default": 20, + "minimum": 1, + "maximum": 200, + "required": false + } + ], + "responses": { + "200": { + "description": "Get the order book of specified market.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/OrderBook" + } + } + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicMarketsMarketOrderBook" + } + }, + "/public/markets": { + "get": { + "description": "Get all available markets.", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Get all available markets.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Market" + } + } + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicMarkets" + } + }, + "/public/currencies": { + "get": { + "description": "Get list of currencies", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "query", + "name": "type", + "description": "Currency type", + "type": "string", + "enum": [ + "fiat", + "coin" + ], + "required": false + } + ], + "responses": { + "200": { + "description": "Get list of currencies", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Currency" + } + } + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicCurrencies" + } + }, + "/public/currencies/{id}": { + "get": { + "description": "Get a currency", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Currency code.", + "type": "string", + "enum": [ + "btc", + "eth", + "trst", + "usd", + "BTC", + "ETH", + "TRST", + "USD" + ], + "required": true + } + ], + "responses": { + "200": { + "description": "Get a currency", + "schema": { + "$ref": "#/definitions/Currency" + } + } + }, + "tags": [ + "public" + ], + "operationId": "getPublicCurrenciesId" + } + }, + "/account/balances/{currency}": { + "get": { + "description": "Get user account by currency", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "path", + "name": "currency", + "description": "The currency code.", + "type": "string", + "enum": [ + "btc", + "eth", + "trst", + "usd" + ], + "required": true + } + ], + "responses": { + "200": { + "description": "Get user account by currency", + "schema": { + "$ref": "#/definitions/Account" + } + } + }, + "tags": [ + "account" + ], + "operationId": "getAccountBalancesCurrency" + } + }, + "/account/balances": { + "get": { + "description": "Get list of user accounts", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Get list of user accounts", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Account" + } + } + } + }, + "tags": [ + "account" + ], + "operationId": "getAccountBalances" + } + }, + "/account/deposit_address/{currency}": { + "get": { + "description": "Returns deposit address for account you want to deposit to by currency. The address may be blank because address generation process is still in progress. If this case you should try again later.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "path", + "name": "currency", + "description": "The account you want to deposit to.", + "type": "string", + "enum": [ + "btc", + "eth", + "trst", + "BTC", + "ETH", + "TRST" + ], + "required": true + }, + { + "in": "query", + "name": "address_format", + "description": "Address format legacy/cash", + "type": "string", + "enum": [ + "legacy", + "cash" + ], + "required": false + } + ], + "responses": { + "200": { + "description": "Returns deposit address for account you want to deposit to by currency. The address may be blank because address generation process is still in progress. If this case you should try again later.", + "schema": { + "$ref": "#/definitions/Deposit" + } + } + }, + "tags": [ + "account" + ], + "operationId": "getAccountDepositAddressCurrency" + } + }, + "/account/deposits/{txid}": { + "get": { + "description": "Get details of specific deposit.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "path", + "name": "txid", + "description": "Deposit transaction id", + "type": "string", + "required": true + } + ], + "responses": { + "200": { + "description": "Get details of specific deposit.", + "schema": { + "$ref": "#/definitions/Deposit" + } + } + }, + "tags": [ + "account" + ], + "operationId": "getAccountDepositsTxid" + } + }, + "/account/deposits": { + "get": { + "description": "Get your deposits history.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "query", + "name": "currency", + "description": "Currency code", + "type": "string", + "enum": [ + "btc", + "eth", + "trst", + "usd", + "BTC", + "ETH", + "TRST", + "USD" + ], + "required": false + }, + { + "in": "query", + "name": "state", + "description": "", + "type": "string", + "enum": [ + "submitted", + "canceled", + "rejected", + "accepted", + "collected" + ], + "required": false + }, + { + "in": "query", + "name": "limit", + "description": "Number of deposits per page (defaults to 100, maximum is 100).", + "type": "integer", + "format": "int32", + "default": 100, + "minimum": 1, + "maximum": 100, + "required": false + }, + { + "in": "query", + "name": "page", + "description": "Page number (defaults to 1).", + "type": "integer", + "format": "int32", + "default": 1, + "required": false + } + ], + "responses": { + "200": { + "description": "Get your deposits history.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Deposit" + } + } + } + }, + "tags": [ + "account" + ], + "operationId": "getAccountDeposits" + } + }, + "/account/withdraws": { + "post": { + "description": "Creates new crypto withdrawal.", + "produces": [ + "application/json" + ], + "consumes": [ + "application/json" + ], + "parameters": [ + { + "in": "formData", + "name": "otp", + "description": "OTP to perform action", + "type": "integer", + "format": "int32", + "required": true + }, + { + "in": "formData", + "name": "rid", + "description": "Wallet address on the Blockchain.", + "type": "string", + "required": true + }, + { + "in": "formData", + "name": "currency", + "description": "The currency code.", + "type": "string", + "enum": [ + "btc", + "eth", + "trst", + "BTC", + "ETH", + "TRST" + ], + "required": true + }, + { + "in": "formData", + "name": "amount", + "description": "The amount to withdraw.", + "type": "number", + "format": "double", + "required": true + }, + { + "in": "formData", + "name": "note", + "description": "Optional metadata to be applied to the transaction. Used to tag transactions with memorable comments.", + "type": "string", + "required": false + } + ], + "responses": { + "201": { + "description": "Creates new crypto withdrawal." + } + }, + "tags": [ + "account" + ], + "operationId": "postAccountWithdraws" + }, + "get": { + "description": "List your withdraws as paginated collection.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "query", + "name": "currency", + "description": "Currency code.", + "type": "string", + "enum": [ + "btc", + "eth", + "trst", + "usd", + "BTC", + "ETH", + "TRST", + "USD" + ], + "required": false + }, + { + "in": "query", + "name": "limit", + "description": "Number of withdraws per page (defaults to 100, maximum is 100).", + "type": "integer", + "format": "int32", + "default": 100, + "minimum": 1, + "maximum": 100, + "required": false + }, + { + "in": "query", + "name": "page", + "description": "Page number (defaults to 1).", + "type": "integer", + "format": "int32", + "default": 1, + "required": false + } + ], + "responses": { + "200": { + "description": "List your withdraws as paginated collection.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Withdraw" + } + } + } + }, + "tags": [ + "account" + ], + "operationId": "getAccountWithdraws" + } + }, + "/market/trades": { + "get": { + "description": "Get your executed trades. Trades are sorted in reverse creation order.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "query", + "name": "market", + "description": "", + "type": "string", + "enum": [ + "btcusd", + "ethbtc", + "ethusd", + "trstbtc", + "trsteth", + "trstusd" + ], + "required": false + }, + { + "in": "query", + "name": "limit", + "description": "Limit the number of returned trades. Default to 100.", + "type": "integer", + "format": "int32", + "default": 100, + "minimum": 1, + "maximum": 1000, + "required": false + }, + { + "in": "query", + "name": "page", + "description": "Specify the page of paginated results.", + "type": "integer", + "format": "int32", + "default": 1, + "required": false + }, + { + "in": "query", + "name": "time_from", + "description": "An integer represents the seconds elapsed since Unix epoch.If set, only trades executed after the time will be returned.", + "type": "integer", + "format": "int32", + "required": false + }, + { + "in": "query", + "name": "time_to", + "description": "An integer represents the seconds elapsed since Unix epoch.If set, only trades executed before the time will be returned.", + "type": "integer", + "format": "int32", + "required": false + }, + { + "in": "query", + "name": "order_by", + "description": "If set, returned trades will be sorted in specific order, default to 'desc'.", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + } + ], + "responses": { + "200": { + "description": "Get your executed trades. Trades are sorted in reverse creation order.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Trade" + } + } + } + }, + "tags": [ + "market" + ], + "operationId": "getMarketTrades" + } + }, + "/market/orders/cancel": { + "post": { + "description": "Cancel all my orders.", + "produces": [ + "application/json" + ], + "consumes": [ + "application/json" + ], + "parameters": [ + { + "in": "formData", + "name": "market", + "description": "", + "type": "string", + "enum": [ + "btcusd", + "ethbtc", + "ethusd", + "trstbtc", + "trsteth", + "trstusd" + ], + "required": false + }, + { + "in": "formData", + "name": "side", + "description": "If present, only sell orders (asks) or buy orders (bids) will be canncelled.", + "type": "string", + "enum": [ + "sell", + "buy" + ], + "required": false + } + ], + "responses": { + "201": { + "description": "Cancel all my orders.", + "schema": { + "$ref": "#/definitions/Order" + } + } + }, + "tags": [ + "market" + ], + "operationId": "postMarketOrdersCancel" + } + }, + "/market/orders/{id}/cancel": { + "post": { + "description": "Cancel an order.", + "produces": [ + "application/json" + ], + "consumes": [ + "application/json" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "", + "type": "integer", + "format": "int32", + "required": true + } + ], + "responses": { + "201": { + "description": "Cancel an order." + } + }, + "tags": [ + "market" + ], + "operationId": "postMarketOrdersIdCancel" + } + }, + "/market/orders": { + "post": { + "description": "Create a Sell/Buy order.", + "produces": [ + "application/json" + ], + "consumes": [ + "application/json" + ], + "parameters": [ + { + "in": "formData", + "name": "market", + "description": "", + "type": "string", + "enum": [ + "btcusd", + "ethbtc", + "ethusd", + "trstbtc", + "trsteth", + "trstusd" + ], + "required": true + }, + { + "in": "formData", + "name": "side", + "description": "", + "type": "string", + "enum": [ + "sell", + "buy" + ], + "required": true + }, + { + "in": "formData", + "name": "volume", + "description": "", + "type": "number", + "format": "double", + "required": true + }, + { + "in": "formData", + "name": "ord_type", + "description": "", + "type": "string", + "default": "limit", + "enum": [ + "market", + "limit" + ], + "required": false + }, + { + "in": "formData", + "name": "price", + "description": "", + "type": "number", + "format": "double", + "required": true + } + ], + "responses": { + "201": { + "description": "Create a Sell/Buy order.", + "schema": { + "$ref": "#/definitions/Order" + } + } + }, + "tags": [ + "market" + ], + "operationId": "postMarketOrders" + }, + "get": { + "description": "Get your orders, results is paginated.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "query", + "name": "market", + "description": "", + "type": "string", + "enum": [ + "btcusd", + "ethbtc", + "ethusd", + "trstbtc", + "trsteth", + "trstusd" + ], + "required": false + }, + { + "in": "query", + "name": "state", + "description": "Filter order by state.", + "type": "string", + "enum": [ + "pending", + "wait", + "done", + "cancel", + "reject" + ], + "required": false + }, + { + "in": "query", + "name": "limit", + "description": "Limit the number of returned orders, default to 100.", + "type": "integer", + "format": "int32", + "default": 100, + "minimum": 0, + "maximum": 1000, + "required": false + }, + { + "in": "query", + "name": "page", + "description": "Specify the page of paginated results.", + "type": "integer", + "format": "int32", + "default": 1, + "required": false + }, + { + "in": "query", + "name": "order_by", + "description": "If set, returned orders will be sorted in specific order, default to \"desc\".", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + { + "in": "query", + "name": "ord_type", + "description": "Filter order by ord_type.", + "type": "string", + "enum": [ + "market", + "limit" + ], + "required": false + }, + { + "in": "query", + "name": "type", + "description": "Filter order by type.", + "type": "string", + "enum": [ + "buy", + "sell" + ], + "required": false + } + ], + "responses": { + "200": { + "description": "Get your orders, results is paginated.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Order" + } + } + } + }, + "tags": [ + "market" + ], + "operationId": "getMarketOrders" + } + }, + "/market/orders/{id}": { + "get": { + "description": "Get information of specified order.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "", + "type": "integer", + "format": "int32", + "required": true + } + ], + "responses": { + "200": { + "description": "Get information of specified order.", + "schema": { + "$ref": "#/definitions/Order" + } + } + }, + "tags": [ + "market" + ], + "operationId": "getMarketOrdersId" + } + } + }, + "definitions": { + "Trade": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Trade ID." + }, + "price": { + "type": "number", + "format": "double", + "description": "Trade price." + }, + "amount": { + "type": "number", + "format": "double", + "description": "Trade amount." + }, + "total": { + "type": "number", + "format": "double", + "description": "Trade total (Amount * Price)." + }, + "market": { + "type": "string", + "description": "Trade market id." + }, + "created_at": { + "type": "string", + "description": "Trade create time in iso8601 format." + }, + "taker_type": { + "type": "string", + "description": "Trade taker order type (sell or buy)." + }, + "side": { + "type": "string", + "description": "Trade side." + }, + "order_id": { + "type": "integer", + "format": "int32", + "description": "Order id." + } + }, + "description": "Get your executed trades. Trades are sorted in reverse creation order." + }, + "OrderBook": { + "type": "object", + "properties": { + "asks": { + "type": "array", + "items": { + "$ref": "#/definitions/Order" + }, + "description": "Asks in orderbook" + }, + "bids": { + "type": "array", + "items": { + "$ref": "#/definitions/Order" + }, + "description": "Bids in orderbook" + } + }, + "description": "Get the order book of specified market." + }, + "Order": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32", + "description": "Unique order id." + }, + "side": { + "type": "string", + "description": "Either 'sell' or 'buy'." + }, + "ord_type": { + "type": "string", + "description": "Type of order, either 'limit' or 'market'." + }, + "price": { + "type": "number", + "format": "double", + "description": "Price for each unit. e.g.If you want to sell/buy 1 btc at 3000 usd, the price is '3000.0'" + }, + "avg_price": { + "type": "number", + "format": "double", + "description": "Average execution price, average of price in trades." + }, + "state": { + "type": "string", + "description": "One of 'wait', 'done', or 'cancel'.An order in 'wait' is an active order, waiting fulfillment;a 'done' order is an order fulfilled;'cancel' means the order has been canceled." + }, + "market": { + "type": "string", + "description": "The market in which the order is placed, e.g. 'btcusd'.All available markets can be found at /api/v2/markets." + }, + "created_at": { + "type": "string", + "description": "Order create time in iso8601 format." + }, + "updated_at": { + "type": "string", + "description": "Order updated time in iso8601 format." + }, + "origin_volume": { + "type": "number", + "format": "double", + "description": "The amount user want to sell/buy.An order could be partially executed,e.g. an order sell 5 btc can be matched with a buy 3 btc order,left 2 btc to be sold; in this case the order's volume would be '5.0',its remaining_volume would be '2.0', its executed volume is '3.0'." + }, + "remaining_volume": { + "type": "number", + "format": "double", + "description": "The remaining volume, see 'volume'." + }, + "executed_volume": { + "type": "number", + "format": "double", + "description": "The executed volume, see 'volume'." + }, + "trades_count": { + "type": "integer", + "format": "int32", + "description": "Count of trades." + }, + "trades": { + "type": "array", + "items": { + "$ref": "#/definitions/Trade" + }, + "description": "Trades wiht this order." + } + }, + "description": "Get your orders, results is paginated." + }, + "Market": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique market id. It's always in the form of xxxyyy,where xxx is the base currency code, yyy is the quotecurrency code, e.g. 'btcusd'. All available markets canbe found at /api/v2/markets." + }, + "name": { + "type": "string", + "description": "Market name." + }, + "base_unit": { + "type": "string", + "description": "Market Base unit." + }, + "quote_unit": { + "type": "string", + "description": "Market Quote unit." + }, + "maker_fee": { + "type": "number", + "format": "double", + "description": "Market maker fee." + }, + "taker_fee": { + "type": "number", + "format": "double", + "description": "Market taker fee." + }, + "min_price": { + "type": "number", + "format": "double", + "description": "Minimum order price." + }, + "max_price": { + "type": "number", + "format": "double", + "description": "Maximum order price." + }, + "min_amount": { + "type": "number", + "format": "double", + "description": "Minimum order amount." + }, + "amount_precision": { + "type": "number", + "format": "double", + "description": "Precision for order amount." + }, + "price_precision": { + "type": "number", + "format": "double", + "description": "Precision for order price." + }, + "state": { + "type": "string", + "description": "Market state defines if user can see/trade on current market." + } + }, + "description": "Get all available markets." + }, + "Currency": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "btc", + "description": "Currency code." + }, + "name": { + "type": "string", + "example": "Bitcoin", + "description": "Currency name" + }, + "symbol": { + "type": "string", + "example": "฿", + "description": "Currency symbol" + }, + "explorer_transaction": { + "type": "string", + "example": "https://testnet.blockchain.info/tx/", + "description": "Currency transaction exprorer url template" + }, + "explorer_address": { + "type": "string", + "example": "https://testnet.blockchain.info/address/", + "description": "Currency address exprorer url template" + }, + "type": { + "type": "string", + "example": "coin", + "description": "Currency type" + }, + "deposit_fee": { + "type": "string", + "example": "0.0", + "description": "Currency deposit fee" + }, + "min_deposit_amount": { + "type": "string", + "example": "0.0000356", + "description": "Minimal deposit amount" + }, + "withdraw_fee": { + "type": "string", + "example": "0.0", + "description": "Currency withdraw fee" + }, + "min_withdraw_amount": { + "type": "string", + "example": "0.0", + "description": "Minimal withdraw amount" + }, + "withdraw_limit_24h": { + "type": "string", + "example": "0.1", + "description": "Currency 24h withdraw limit" + }, + "withdraw_limit_72h": { + "type": "string", + "example": "0.2", + "description": "Currency 72h withdraw limit" + }, + "base_factor": { + "type": "string", + "example": 100000000, + "description": "Currency base factor" + }, + "precision": { + "type": "string", + "example": 8, + "description": "Currency precision" + }, + "icon_url": { + "type": "string", + "example": "https://upload.wikimedia.org/wikipedia/commons/0/05/Ethereum_logo_2014.svg", + "description": "Currency icon" + }, + "min_confirmations": { + "type": "string", + "description": "Number of confirmations required for confirming deposit or withdrawal" + } + }, + "description": "Get a currency" + }, + "Account": { + "type": "object", + "properties": { + "currency": { + "type": "string", + "description": "Currency code." + }, + "balance": { + "type": "number", + "format": "double", + "description": "Account balance." + }, + "locked": { + "type": "number", + "format": "double", + "description": "Account locked funds." + } + }, + "description": "Get list of user accounts" + }, + "Deposit": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32", + "description": "Unique deposit id." + }, + "currency": { + "type": "string", + "description": "Deposit currency id." + }, + "amount": { + "type": "number", + "format": "double", + "description": "Deposit amount." + }, + "fee": { + "type": "number", + "format": "double", + "description": "Deposit fee." + }, + "txid": { + "type": "string", + "description": "Deposit transaction id." + }, + "confirmations": { + "type": "integer", + "format": "int32", + "description": "Number of deposit confirmations." + }, + "state": { + "type": "string", + "description": "Deposit state." + }, + "created_at": { + "type": "string", + "description": "The datetime when deposit was created." + }, + "completed_at": { + "type": "string", + "description": "The datetime when deposit was completed.." + } + }, + "description": "Get your deposits history." + }, + "Withdraw": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32", + "description": "The withdrawal id." + }, + "currency": { + "type": "string", + "description": "The currency code." + }, + "type": { + "type": "string", + "description": "The withdrawal type" + }, + "amount": { + "type": "string", + "description": "The withdrawal amount" + }, + "fee": { + "type": "number", + "format": "double", + "description": "The exchange fee." + }, + "blockchain_txid": { + "type": "string", + "description": "The withdrawal transaction id." + }, + "rid": { + "type": "string", + "description": "The beneficiary ID or wallet address on the Blockchain." + }, + "state": { + "type": "string", + "description": "The withdrawal state." + }, + "confirmations": { + "type": "integer", + "format": "int32", + "description": "Number of confirmations." + }, + "note": { + "type": "string", + "description": "Withdraw note." + }, + "created_at": { + "type": "string", + "description": "The datetimes for the withdrawal." + }, + "updated_at": { + "type": "string", + "description": "The datetimes for the withdrawal." + }, + "done_at": { + "type": "string", + "description": "The datetime when withdraw was completed" + } + }, + "description": "List your withdraws as paginated collection." + }, + "Member": { + "type": "object", + "properties": { + "uid": { + "type": "string", + "description": "Member UID." + }, + "email": { + "type": "string", + "description": "Member email." + }, + "accounts": { + "type": "array", + "items": { + "$ref": "#/definitions/Account" + }, + "description": "Member accounts." + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e148cfc --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "shahoo-bff", + "version": "0.1.0", + "private": true, + "description": "BFF adapter: Shahoo /api/1/* → Fibitex Dalan/Dena", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "check:ranger": "node scripts/check-ranger-credentials.js" + }, + "engines": { + "node": ">=16" + }, + "dependencies": { + "axios": "^1.6.8", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "ioredis": "^5.4.1", + "multer": "^1.4.5-lts.1", + "uuid": "^9.0.1", + "ws": "^8.21.1" + } +} diff --git a/scripts/check-map-ohlcv.js b/scripts/check-map-ohlcv.js new file mode 100644 index 0000000..752d55d --- /dev/null +++ b/scripts/check-map-ohlcv.js @@ -0,0 +1,28 @@ +/** ponytail: assert TV resolutions map 1:1 to Dena periods (no 180→240 / 480→720). */ +const assert = require("assert"); +const { tradingViewResolutionToDena } = require("../src/lib/mapOhlcv"); + +const cases = [ + ["60", 60], + ["120", 120], + ["240", 240], + ["360", 360], + ["720", 720], + ["1D", 1440], + ["D", 1440], + ["d", 1440], +]; + +for (const [res, period] of cases) { + assert.strictEqual( + tradingViewResolutionToDena(res), + period, + `${res} → ${period}` + ); +} + +// Fake intervals must NOT silently alias to a different bucket. +assert.strictEqual(tradingViewResolutionToDena("180"), 180); +assert.strictEqual(tradingViewResolutionToDena("480"), 480); + +console.log("ok: mapOhlcv TV resolutions"); diff --git a/scripts/check-ranger-credentials.js b/scripts/check-ranger-credentials.js new file mode 100644 index 0000000..fa975f6 --- /dev/null +++ b/scripts/check-ranger-credentials.js @@ -0,0 +1,30 @@ +/** ponytail: assert ranger credentials + upstream URL/stream defaults. */ +const assert = require("assert"); +const { + defaultPrivateStreams, + buildUpstreamUrl, + cookieUpstreamHeaders, +} = require("../src/routes/ranger"); + +const streams = defaultPrivateStreams(); +assert.ok(streams.includes("order")); +assert.ok(streams.includes("trade")); +assert.ok(!streams.includes("balances"), "balances off by default (Finex)"); + +const url = buildUpstreamUrl( + ["trade", "order"], + "ws://rango:8080/api/v2/ranger/private" +); +assert.ok(url.includes("/api/v2/ranger/private")); +assert.ok(url.includes("stream=order")); +assert.ok(url.includes("stream=trade")); +assert.ok(!url.includes("balances")); + +const headers = cookieUpstreamHeaders({ + cookies: "a=1", + csrfToken: "csrf", +}); +assert.strictEqual(headers.Cookie, "a=1"); +assert.strictEqual(headers["X-CSRF-Token"], "csrf"); + +console.log("check-ranger-credentials: ok"); diff --git a/scripts/patch-tv-tehran-tz.js b/scripts/patch-tv-tehran-tz.js new file mode 100644 index 0000000..541fc3e --- /dev/null +++ b/scripts/patch-tv-tehran-tz.js @@ -0,0 +1,79 @@ +/** + * Patch TradingView charting_library Asia/Tehran zone: + * Iran abolished DST in 2022 — permanently UTC+03:30 (12600s). + * + * IMPORTANT: TV binary-search returns -1 (→ offset 0 / UTC) when "now" + * is past the LAST transition. A single-entry zone breaks modern charts. + * Mirror Asia/Dubai: keep a far-future sentinel as the last time entry. + */ +const fs = require("fs"); +const path = require("path"); + +// Both offsets 12600: permanent UTC+03:30 (no DST). +// Sentinel ~2030 so current timestamps resolve to index 0/1 with +3:30, not UTC. +const FIXED = + '"Asia/Tehran":{time:[-1704153600,1925006400],offset:[12600,12600]}'; + +const FILES = [ + path.resolve( + __dirname, + "../../Gereh/src/charting_library/static/bundles/library.e964cdc99937c68d0389.js" + ), + path.resolve( + __dirname, + "../../Gereh/public/charting_library/static/bundles/library.e964cdc99937c68d0389.js" + ), + path.resolve( + __dirname, + "../../Shahoo/public/charting_library/static/bundles/library.e964cdc99937c68d0389.js" + ), + path.resolve( + __dirname, + "../../Shahoo/src/features/Market/MainComponents/TVChartContainer/charting_library/static/bundles/library.e964cdc99937c68d0389.js" + ), +]; + +function extractTehran(s) { + const key = '"Asia/Tehran":{'; + const i = s.indexOf(key); + if (i < 0) return null; + let depth = 0; + let end = -1; + for (let j = i + key.length - 1; j < s.length; j++) { + if (s[j] === "{") depth++; + else if (s[j] === "}") { + depth--; + if (depth === 0) { + end = j + 1; + break; + } + } + } + if (end < 0) return null; + return { start: i, end, text: s.slice(i, end) }; +} + +let patched = 0; +for (const file of FILES) { + if (!fs.existsSync(file)) { + console.log("skip missing:", file); + continue; + } + const src = fs.readFileSync(file, "utf8"); + const hit = extractTehran(src); + if (!hit) { + console.log("skip no Tehran:", file); + continue; + } + if (hit.text === FIXED) { + console.log("already patched:", file); + continue; + } + fs.writeFileSync(file, src.slice(0, hit.start) + FIXED + src.slice(hit.end)); + console.log("patched:", path.relative(process.cwd(), file)); + console.log(" was:", hit.text); + console.log(" now:", FIXED); + patched++; +} + +console.log("done, patched", patched, "file(s)"); diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..21b7ff2 --- /dev/null +++ b/src/config.js @@ -0,0 +1,46 @@ +require("dotenv").config(); + +function parseCorsOrigins(value) { + if (!value) { + return ["http://localhost:3000", "http://127.0.0.1:3000"]; + } + return String(value) + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function parseNumber(value, fallback) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +module.exports = { + port: Number(process.env.PORT || 8082), + corsOrigins: parseCorsOrigins(process.env.CORS_ORIGIN), + dalanUrl: process.env.DALAN_URL || "http://www.app.local/api/v2/dalan", + denaUrl: process.env.DENA_URL || "http://www.app.local/api/v2/dena", + /** Host header for in-cluster calls via gateway (e.g. www.app.local). */ + upstreamHost: process.env.FIBITEX_HOST || process.env.UPSTREAM_HOST || "", + publicUrl: + process.env.SHAAHO_PUBLIC_URL || + process.env.FIBITEX_PUBLIC_URL || + "http://shahoo.app.local", + vipThresholds: { + vip0: 0, + vip1: parseNumber(process.env.VIP_1_AMOUNT, 10_000_000), + vip2: parseNumber(process.env.VIP_2_AMOUNT, 50_000_000), + vip3: parseNumber(process.env.VIP_3_AMOUNT, 200_000_000), + vip4: parseNumber(process.env.VIP_4_AMOUNT, 500_000_000), + vipMax: parseNumber(process.env.VIP_4_AMOUNT, 500_000_000) * 2, + }, + jibitApiUrl: process.env.JIBIT_API_URL || "https://napi.jibit.ir/ide/v1", + jibitApiKey: process.env.JIBIT_API_KEY || "", + jibitApiSecret: process.env.JIBIT_API_SECRET || "", + /** Local dev only — set SKIP_API_KEY_2FA=true in .env */ + skipApiKey2FA: process.env.SKIP_API_KEY_2FA === "true", + publicCallbackUrl: + process.env.FIBITEX_PUBLIC_URL || process.env.SHAAHO_PUBLIC_URL || "http://shahoo.app.local", + rangerPrivateUrl: + process.env.RANGER_PRIVATE_URL || "ws://gateway:8099/api/v2/ranger/private", +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..4878ce6 --- /dev/null +++ b/src/index.js @@ -0,0 +1,46 @@ +const http = require("http"); +const express = require("express"); +const cors = require("cors"); +const config = require("./config"); +const healthRouter = require("./routes/health"); +const apiRouter = require("./routes/api"); +const { attachRangerPrivateProxy } = require("./routes/ranger"); + +const app = express(); + +app.use( + cors({ + origin(origin, callback) { + if (!origin || config.corsOrigins.includes(origin)) { + callback(null, origin || config.corsOrigins[0]); + return; + } + callback(new Error(`CORS blocked origin: ${origin}`)); + }, + credentials: true, + }) +); +app.use(express.json()); + +app.use(healthRouter); +app.use("/api/1", apiRouter); + +app.use((err, _req, res, _next) => { + console.error(err); + res.status(500).json({ + statusCode: 500, + message: "Internal server error", + content: null, + }); +}); + +const server = http.createServer(app); +attachRangerPrivateProxy(server); + +server.listen(config.port, () => { + console.log(`Shahoo BFF listening on http://localhost:${config.port}`); + console.log(` Dalan: ${config.dalanUrl}`); + console.log(` Dena: ${config.denaUrl}`); + console.log(` CORS: ${config.corsOrigins.join(", ")}`); + console.log(` Ranger private proxy: /api/1/ranger/ws`); +}); diff --git a/src/lib/buildApiSpec.js b/src/lib/buildApiSpec.js new file mode 100644 index 0000000..d9eff18 --- /dev/null +++ b/src/lib/buildApiSpec.js @@ -0,0 +1,295 @@ +const config = require("../config"); + +const DENA_BASE_PATH = "/api/v2/dena"; + +function buildAuthDescription({ brandName, apiBaseUrl }) { + return [ + `# ${brandName} REST API`, + "", + "Programmatic access to market data, account balances, deposits, withdrawals, and order management.", + "", + "## Prerequisites", + "", + "1. Enable **Google Authenticator (2FA)** on your account.", + "2. Create an API key from the exchange security settings (secret shown once).", + "", + "## Base URL", + "", + `\`${apiBaseUrl}\``, + "", + "## Authentication (HMAC)", + "", + "Private endpoints require these headers on every request:", + "", + "| Header | Description |", + "| --- | --- |", + "| `X-Auth-Apikey` | Your Access Key (`kid`) |", + "| `X-Auth-Nonce` | Current Unix time in **milliseconds** (valid ~5 seconds) |", + "| `X-Auth-Signature` | `HMAC-SHA256(secret, nonce + accessKey)` as lowercase hex |", + "", + "### Node.js example", + "", + "```javascript", + "const crypto = require('crypto');", + "const accessKey = 'YOUR_ACCESS_KEY';", + "const secretKey = 'YOUR_SECRET_KEY';", + "const nonce = Date.now().toString();", + "const signature = crypto", + " .createHmac('sha256', secretKey)", + " .update(nonce + accessKey)", + " .digest('hex');", + "", + `fetch('${apiBaseUrl}/account/balances', {`, + " headers: {", + " 'X-Auth-Apikey': accessKey,", + " 'X-Auth-Nonce': nonce,", + " 'X-Auth-Signature': signature,", + " },", + "});", + "```", + "", + "### Python example", + "", + "```python", + "import hashlib, hmac, time, requests", + "access_key = 'YOUR_ACCESS_KEY'", + "secret_key = 'YOUR_SECRET_KEY'", + "nonce = str(int(time.time() * 1000))", + "signature = hmac.new(", + " secret_key.encode(),", + " (nonce + access_key).encode(),", + " hashlib.sha256,", + ").hexdigest()", + "", + `requests.get('${apiBaseUrl}/account/balances', headers={`, + " 'X-Auth-Apikey': access_key,", + " 'X-Auth-Nonce': nonce,", + " 'X-Auth-Signature': signature,", + "})", + "```", + ].join("\n"); +} + +function resolveApiOrigin() { + const denaUrl = String(config.denaUrl || "").trim(); + if (denaUrl) { + try { + const url = new URL(denaUrl.startsWith("http") ? denaUrl : `http://${denaUrl}`); + const scheme = url.protocol.replace(":", ""); + return { + host: url.host, + schemes: [scheme === "https" ? "https" : "http"], + basePath: DENA_BASE_PATH, + apiBaseUrl: `${url.protocol}//${url.host}${DENA_BASE_PATH}`, + }; + } catch { + /* fall through */ + } + } + + const domain = String(process.env.FIBITEX_DOMAIN || process.env.APP_DOMAIN || "fibitex.com") + .replace(/^https?:\/\//, "") + .split("/")[0]; + + return { + host: domain, + schemes: ["https"], + basePath: DENA_BASE_PATH, + apiBaseUrl: `https://${domain}${DENA_BASE_PATH}`, + }; +} + +function normalizePathKey(path) { + let normalized = String(path || "").trim(); + if (!normalized.startsWith("/")) { + normalized = `/${normalized}`; + } + + for (const prefix of ["/api/v2/dena", "/api/v2"]) { + if (normalized.startsWith(prefix)) { + normalized = normalized.slice(prefix.length) || "/"; + break; + } + } + + if (!normalized.startsWith("/")) { + normalized = `/${normalized}`; + } + + return normalized; +} + +function capitalizeWord(word) { + if (!word) return ""; + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); +} + +function humanizeSegment(segment) { + return String(segment || "") + .replace(/\{|\}/g, "") + .split(/[_-]/) + .filter(Boolean) + .map(capitalizeWord) + .join(" "); +} + +function friendlyOperationName(operation, method, path) { + if (operation.summary && !/^getApi/i.test(operation.summary)) { + return operation.summary; + } + + const segments = path.split("/").filter(Boolean); + const skip = new Set(["public", "account", "market", "api", "v2", "dena"]); + const meaningful = segments.filter((s) => !skip.has(s.toLowerCase()) && !/^\{/.test(s)); + + if (meaningful.length >= 2) { + return meaningful.map(humanizeSegment).join(" "); + } + + if (meaningful.length === 1) { + return humanizeSegment(meaningful[0]); + } + + const opId = String(operation.operationId || ""); + const stripped = opId + .replace(/^(get|post|put|patch|delete)/i, "") + .replace(/^ApiV2Dena(Public|Account|Market)?/i, "") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .trim(); + + if (stripped) { + return stripped + .split(/\s+/) + .map(capitalizeWord) + .join(" "); + } + + return humanizeSegment(segments[segments.length - 1] || method); +} + +function isPublicPath(path) { + return path === "/public" || path.startsWith("/public/"); +} + +function resolveMenuTag(operation, path) { + const rawTag = String(operation.tags?.[0] || "").toLowerCase(); + + if (rawTag === "public" || rawTag === "account" || rawTag === "market") { + return capitalizeWord(rawTag); + } + + if (isPublicPath(path)) { + return "Public"; + } + + if (path.startsWith("/market")) { + return "Market"; + } + + return "Account"; +} + +function enhanceOperation(operation, method, path) { + const isPublic = isPublicPath(path); + const summary = friendlyOperationName(operation, method, path); + const tag = resolveMenuTag(operation, path); + + return { + ...operation, + summary, + tags: [tag], + security: isPublic ? [] : operation.security ?? [{ ApiKeyAuth: [] }, { Bearer: [] }], + }; +} + +function normalizePaths(spec) { + const nextPaths = {}; + + Object.entries(spec.paths || {}).forEach(([rawPath, pathItem]) => { + const path = normalizePathKey(rawPath); + const enhanced = { ...(nextPaths[path] || {}) }; + + Object.entries(pathItem || {}).forEach(([key, value]) => { + if (key === "parameters") { + enhanced.parameters = value; + return; + } + + if (["get", "post", "put", "patch", "delete", "head", "options"].includes(key)) { + enhanced[key] = enhanceOperation(value, key, path); + } + }); + + nextPaths[path] = enhanced; + }); + + spec.paths = nextPaths; +} + +function buildBrandedSpec(rawSpec, { brandName, publicUrl, logoUrl }) { + const spec = JSON.parse(JSON.stringify(rawSpec || {})); + const apiOrigin = resolveApiOrigin(); + + spec.swagger = spec.swagger || "2.0"; + spec.host = apiOrigin.host; + spec.schemes = apiOrigin.schemes; + spec.basePath = apiOrigin.basePath; + spec.produces = spec.produces || ["application/json"]; + + normalizePaths(spec); + + spec.info = { + ...(spec.info || {}), + title: "Fibitex API Doc", + description: buildAuthDescription({ + brandName, + apiBaseUrl: apiOrigin.apiBaseUrl, + }), + version: spec.info?.version || "2.6.0", + contact: { + name: `${brandName} Support`, + email: spec.info?.contact?.email || "support@fibitex.com", + url: spec.info?.contact?.url || apiOrigin.apiBaseUrl.replace(/\/api\/v2\/dena$/, ""), + }, + }; + + spec.tags = [ + { + name: "Public", + description: "Public market data and health checks. No authentication required.", + }, + { + name: "Account", + description: "Account balances, deposits, and withdrawals. Requires HMAC API key and active 2FA.", + }, + { + name: "Market", + description: "Order placement, cancellation, and trade history. Requires HMAC API key and active 2FA.", + }, + ]; + + delete spec["x-tagGroups"]; + + spec.securityDefinitions = { + ...(spec.securityDefinitions || {}), + ApiKeyAuth: { + type: "apiKey", + name: "X-Auth-Apikey", + in: "header", + description: + "HMAC API key authentication. Send X-Auth-Nonce and X-Auth-Signature headers as well.", + }, + Bearer: { + type: "apiKey", + name: "Authorization", + in: "header", + description: "JWT Bearer token issued by the auth gateway after API key validation.", + }, + }; + + spec.security = [{ ApiKeyAuth: [] }]; + + return spec; +} + +module.exports = { buildBrandedSpec, buildAuthDescription, resolveApiOrigin, normalizePathKey }; diff --git a/src/lib/dalanClient.js b/src/lib/dalanClient.js new file mode 100644 index 0000000..0efb3e9 --- /dev/null +++ b/src/lib/dalanClient.js @@ -0,0 +1,423 @@ +const axios = require("axios"); +const { v4: uuidv4 } = require("uuid"); +const config = require("../config"); +const sessionStore = require("./sessionStore"); +const { upstreamHeaders, sessionHeaders } = require("./upstreamHeaders"); + +function cookieHeader(setCookie) { + if (!setCookie) return ""; + const list = Array.isArray(setCookie) ? setCookie : [setCookie]; + return list.map((c) => c.split(";")[0]).join("; "); +} + +function createSessionFromAuthResponse(res) { + const cookies = cookieHeader(res.headers["set-cookie"]); + const csrfToken = res.data?.csrf_token; + const token = uuidv4(); + + sessionStore.create({ + token, + cookies, + csrfToken, + dalanUser: res.data, + }); + + return { token, user: res.data }; +} + +async function login({ email, password, otpCode, captcha }) { + const body = { email, password }; + if (otpCode) body.otp_code = otpCode; + if (captcha) body.captcha_response = captcha; + + const res = await axios.post( + `${config.dalanUrl}/identity/sessions`, + body, + { headers: upstreamHeaders(), validateStatus: () => true } + ); + + if (res.status === 401 && res.data?.errors?.includes("identity.session.missing_otp")) { + return { needs2fa: true }; + } + + if (res.status !== 200) { + const msg = + res.data?.errors?.[0] || + res.data?.error || + "Invalid Email or Password"; + const err = new Error(msg); + err.statusCode = res.status; + throw err; + } + + const session = createSessionFromAuthResponse(res); + return { ...session, needs2fa: false }; +} + +async function getMe(session) { + const res = await axios.get(`${config.dalanUrl}/resource/users/me`, { + headers: sessionHeaders(session), + validateStatus: () => true, + }); + + if (res.status !== 200) { + const err = new Error("Unauthorized"); + err.statusCode = 401; + throw err; + } + + session.dalanUser = res.data; + return res.data; +} + +async function logout(session) { + await axios.delete(`${config.dalanUrl}/identity/sessions`, { + headers: sessionHeaders(session), + validateStatus: () => true, + }); +} + +function dalanErrorMessage(data) { + if (!data) return "Request failed"; + if (Array.isArray(data.errors) && data.errors.length) { + return String(data.errors[0]); + } + return data.error || "Request failed"; +} + +async function register({ email, password, refid, captcha }) { + const body = { email, password }; + if (refid) body.refid = refid; + if (captcha) body.captcha_response = captcha; + + const res = await axios.post(`${config.dalanUrl}/identity/users`, body, { + headers: upstreamHeaders(), + validateStatus: () => true, + }); + return res; +} + +async function confirmEmailActivation({ email, code, captcha }) { + const body = { email, code }; + if (captcha) body.captcha_response = captcha; + + const res = await axios.post( + `${config.dalanUrl}/identity/users/email/confirm_email`, + body, + { headers: upstreamHeaders(), validateStatus: () => true } + ); + return res; +} + +async function confirmEmailByLinkToken({ token, captcha }) { + const body = { token }; + if (captcha) body.captcha_response = captcha; + + const res = await axios.post( + `${config.dalanUrl}/identity/users/email/confirm_code`, + body, + { headers: upstreamHeaders(), validateStatus: () => true } + ); + return res; +} + +async function forgotPassword({ email, captcha }) { + const body = { email }; + if (captcha) body.captcha_response = captcha; + + const res = await axios.post( + `${config.dalanUrl}/identity/users/password/generate_code`, + body, + { headers: upstreamHeaders(), validateStatus: () => true } + ); return res; +} + +async function confirmForgotCode({ email, code, captcha }) { + const body = { email, code }; + if (captcha) body.captcha_response = captcha; + + const res = await axios.post( + `${config.dalanUrl}/identity/users/password/confirm_code`, + body, + { headers: upstreamHeaders(), validateStatus: () => true } + ); return res; +} + +async function resetPassword({ email, code, password, confirmPassword, captcha }) { + const body = { + email, + code, + password, + confirm_password: confirmPassword || password, + }; + if (captcha) body.captcha_response = captcha; + + const res = await axios.post( + `${config.dalanUrl}/identity/users/password/reset`, + body, + { headers: upstreamHeaders(), validateStatus: () => true } + ); return res; +} + +async function getUserActivity( + session, + topic, + { limit = 10, page = 1, timeFrom, timeTo, result } = {} +) { + const params = { limit, page }; + if (timeFrom) params.time_from = timeFrom; + if (timeTo) params.time_to = timeTo; + if (result) params.result = result; + + const res = await axios.get( + `${config.dalanUrl}/resource/users/activity/${topic}`, + { + headers: sessionHeaders(session), + params, + validateStatus: () => true, + } + ); + + if (res.status !== 200) { + return { items: [], total: 0 }; + } + + const totalHeader = + res.headers?.total || res.headers?.["x-total"] || res.headers?.["X-Total"]; + const total = parseInt(totalHeader, 10); + const items = Array.isArray(res.data) ? res.data : []; + + return { + items, + total: Number.isFinite(total) ? total : items.length, + }; +} + +async function resourcePost(session, path, body = {}) { + const res = await axios.post(`${config.dalanUrl}${path}`, body, { + headers: sessionHeaders(session), + validateStatus: () => true, + }); + return res; +} + +async function resourcePut(session, path, body = {}) { + const res = await axios.put(`${config.dalanUrl}${path}`, body, { + headers: sessionHeaders(session), + validateStatus: () => true, + }); + return res; +} + +async function resourcePatch(session, path, body = {}) { + const res = await axios.patch(`${config.dalanUrl}${path}`, body, { + headers: sessionHeaders(session), + validateStatus: () => true, + }); + return res; +} + +async function generateOtpQrCode(session) { + return resourcePost(session, "/resource/otp/generate_qrcode"); +} + +async function enableOtp(session, code) { + return resourcePost(session, "/resource/otp/enable", { code }); +} + +async function enableOtpEmail(session, code) { + return resourcePost(session, "/resource/otp/enable_2fa", { code }); +} + +async function disableOtp(session, code) { + return resourcePost(session, "/resource/otp/disable", { code }); +} + +async function disableOtpEmail(session, code) { + return resourcePost(session, "/resource/otp/disable_email", { code }); +} + +async function changePassword(session, { oldPassword, password, confirmPassword }) { + return resourcePut(session, "/resource/users/password", { + old_password: oldPassword, + new_password: password, + confirm_password: confirmPassword || password, + }); +} + +async function resourceGet(session, path, params = {}) { + const res = await axios.get(`${config.dalanUrl}${path}`, { + headers: sessionHeaders(session), + params, + validateStatus: () => true, + }); + return res; +} + +async function resourceDelete(session, path) { + const res = await axios.delete(`${config.dalanUrl}${path}`, { + headers: sessionHeaders(session), + validateStatus: () => true, + }); + return res; +} + +async function getFullUser(session) { + const res = await resourceGet(session, "/resource/users/me"); + if (res.status !== 200) { + const err = new Error("Unauthorized"); + err.statusCode = 401; + throw err; + } + session.dalanUser = res.data; + return res.data; +} + +async function getDocuments(session) { + const res = await resourceGet(session, "/resource/documents"); + return res.status === 200 && Array.isArray(res.data) ? res.data : []; +} + +async function getTreasuries(session) { + const res = await resourceGet(session, "/resource/profiles/treasury/list"); + return res.status === 200 && Array.isArray(res.data) ? res.data : []; +} + +async function postMultipart(session, path, fields, file) { + const form = new FormData(); + Object.entries(fields).forEach(([key, value]) => { + if (value != null && value !== "") form.append(key, value); + }); + if (file?.buffer) { + form.append("upload", new Blob([file.buffer]), file.originalname || "upload.bin"); + } + + const res = await axios.post(`${config.dalanUrl}${path}`, form, { + headers: { + ...sessionHeaders(session), + "Content-Type": "multipart/form-data", + }, + validateStatus: () => true, + }); + return res; +} + +async function putMultipart(session, path, fields, file) { + const form = new FormData(); + Object.entries(fields).forEach(([key, value]) => { + if (value != null && value !== "") form.append(key, value); + }); + if (file?.buffer) { + form.append("upload", new Blob([file.buffer]), file.originalname || "upload.bin"); + } + + const res = await axios.put(`${config.dalanUrl}${path}`, form, { + headers: { + ...sessionHeaders(session), + "Content-Type": "multipart/form-data", + }, + validateStatus: () => true, + }); + return res; +} + +async function createIdentityProfile(session, { profileFields, file }) { + return postMultipart(session, "/resource/profiles", profileFields, file); +} + +async function updateIdentityProfile(session, { profileFields, file }) { + return putMultipart(session, "/resource/profiles", profileFields, file); +} + +async function submitAddress(session, { addressFields, file }) { + return postMultipart(session, "/resource/profiles/address", addressFields, file); +} + +async function submitSelfie(session, file) { + return postMultipart(session, "/resource/profiles/selfie", {}, file); +} + +async function createTreasury(session, payload) { + return resourcePost(session, "/resource/profiles/treasury", payload); +} + +async function deleteTreasury(session, id) { + return resourceDelete(session, `/resource/profiles/treasury/${id}`); +} + +async function sendMobileCode(session, phoneNumber) { + return resourcePost(session, "/resource/mobiles", { phone_number: phoneNumber }); +} + +async function resendMobileCode(session, phoneNumber) { + return resourcePost(session, "/resource/mobiles/send_code", { phone_number: phoneNumber }); +} + +async function verifyMobileCode(session, phoneNumber, verificationCode) { + return resourcePost(session, "/resource/mobiles/verify", { + phone_number: phoneNumber, + verification_code: verificationCode, + }); +} + +async function addLandlinePhone(session, phoneNumber) { + return resourcePost(session, "/resource/phones", { phone_number: phoneNumber }); +} + +async function resendLandlineCode(session, phoneNumber) { + return resourcePost(session, "/resource/phones/send_code", { phone_number: phoneNumber }); +} + +async function verifyLandlineCode(session, phoneNumber, verificationCode) { + return resourcePost(session, "/resource/phones/verify", { + phone_number: phoneNumber, + verification_code: verificationCode, + }); +} + +async function verifyOtpCode(session, code) { + return resourcePost(session, "/resource/otp/verify", { code }); +} + +module.exports = { + login, + getMe, + logout, + cookieHeader, + dalanErrorMessage, + register, + confirmEmailActivation, + confirmEmailByLinkToken, + createSessionFromAuthResponse, + forgotPassword, + confirmForgotCode, + resetPassword, + getUserActivity, + generateOtpQrCode, + enableOtp, + enableOtpEmail, + disableOtp, + disableOtpEmail, + changePassword, + resourceGet, + resourcePost, + resourcePut, + resourcePatch, + resourceDelete, + getFullUser, + getDocuments, + getTreasuries, + createIdentityProfile, + updateIdentityProfile, + submitAddress, + submitSelfie, + createTreasury, + deleteTreasury, + sendMobileCode, + resendMobileCode, + verifyMobileCode, + addLandlinePhone, + resendLandlineCode, + verifyLandlineCode, + verifyOtpCode, +}; diff --git a/src/lib/denaClient.js b/src/lib/denaClient.js new file mode 100644 index 0000000..8187aa0 --- /dev/null +++ b/src/lib/denaClient.js @@ -0,0 +1,520 @@ +const axios = require("axios"); +const config = require("../config"); +const { upstreamHeaders, sessionHeaders } = require("./upstreamHeaders"); + +async function getTickers() { + const res = await axios.get(`${config.denaUrl}/public/markets/tickers`, { + headers: upstreamHeaders(), + validateStatus: () => true, + }); if (res.status !== 200) { + return {}; + } + return res.data || {}; +} + +async function getMarkets() { + const res = await axios.get(`${config.denaUrl}/public/markets`, { + headers: upstreamHeaders(), + validateStatus: () => true, + }); if (res.status !== 200) { + return []; + } + return Array.isArray(res.data) ? res.data : []; +} + +async function getCurrencies() { + const res = await axios.get(`${config.denaUrl}/public/currencies`, { + headers: upstreamHeaders(), + validateStatus: () => true, + }); + if (res.status !== 200) { + return []; + } + return Array.isArray(res.data) ? res.data : []; +} + +async function getBalances(session) { + const res = await axios.get(`${config.denaUrl}/account/balances`, { + headers: sessionHeaders(session), + validateStatus: () => true, + }); if (res.status !== 200) { + return []; + } + return Array.isArray(res.data) ? res.data : []; +} + +async function getOrderBook(marketId, { limit = 16 } = {}) { + const res = await axios.get( + `${config.denaUrl}/public/markets/${marketId}/depth`, + { params: { limit }, headers: upstreamHeaders(), validateStatus: () => true } + ); if (res.status !== 200) { + return { asks: [], bids: [] }; + } + return res.data || { asks: [], bids: [] }; +} + +async function getOrders(session, { state = "wait", market, limit = 100, page = 1 } = {}) { + const params = { state, limit, page }; + if (market) params.market = market; + + const res = await axios.get(`${config.denaUrl}/market/orders`, { + headers: sessionHeaders(session), + params, + validateStatus: () => true, + }); + if (res.status !== 200) { + return []; + } + return Array.isArray(res.data) ? res.data : []; +} + +async function createOrder(session, body) { + const res = await axios.post(`${config.denaUrl}/market/orders`, body, { + headers: { + ...sessionHeaders(session), + "Content-Type": "application/json", + }, + validateStatus: () => true, + }); + return res; +} + +async function cancelOrder(session, id) { + const res = await axios.post( + `${config.denaUrl}/market/orders/${id}/cancel`, + null, + { + headers: sessionHeaders(session), + validateStatus: () => true, + } + ); + return res; +} + +async function cancelAllOrders(session, { market, side } = {}) { + const params = new URLSearchParams(); + if (market) params.append("market", market); + if (side) params.append("side", side); + + const res = await axios.post( + `${config.denaUrl}/market/orders/cancel`, + params.toString(), + { + headers: { + ...sessionHeaders(session), + "Content-Type": "application/x-www-form-urlencoded", + }, + validateStatus: () => true, + } + ); + return res; +} + +async function getKLine(marketId, { period = 60, limit = 30, timeFrom, timeTo } = {}) { + const params = { period, limit }; + if (timeFrom) params.time_from = timeFrom; + if (timeTo) params.time_to = timeTo; + + const res = await axios.get( + `${config.denaUrl}/public/markets/${marketId}/k-line`, + { params, headers: upstreamHeaders(), validateStatus: () => true } + ); if (res.status !== 200) { + return []; + } + return Array.isArray(res.data) ? res.data : []; +} + +async function getPublicTrades(marketId, { limit = 50 } = {}) { + const res = await axios.get( + `${config.denaUrl}/public/markets/${marketId}/trades`, + { params: { limit }, headers: upstreamHeaders(), validateStatus: () => true } + ); if (res.status !== 200) { + return []; + } + return Array.isArray(res.data) ? res.data : []; +} + +async function getTradingFees() { + const res = await axios.get(`${config.denaUrl}/public/trading_fees`, { + headers: upstreamHeaders(), + validateStatus: () => true, + }); if (res.status !== 200) { + return []; + } + return Array.isArray(res.data) ? res.data : []; +} + +async function getDepositAddress(session, currency) { + const res = await axios.get( + `${config.denaUrl}/account/deposit_address/${String(currency).toLowerCase()}`, + { headers: sessionHeaders(session), validateStatus: () => true } + ); + return res; +} + +async function getDeposits(session, { limit = 100, page = 1, currency } = {}) { + const params = { limit, page }; + if (currency) params.currency = String(currency).toLowerCase(); + + const res = await axios.get(`${config.denaUrl}/account/deposits`, { + headers: sessionHeaders(session), + params, + validateStatus: () => true, + }); + if (res.status !== 200) { + return []; + } + return Array.isArray(res.data) ? res.data : []; +} + +async function getWithdrawals(session, { limit = 100, page = 1 } = {}) { + const res = await axios.get(`${config.denaUrl}/account/withdraws`, { + headers: sessionHeaders(session), + params: { limit, page }, + validateStatus: () => true, + }); + if (res.status !== 200) { + return []; + } + return Array.isArray(res.data) ? res.data : []; +} + +async function getBonus(session) { + const res = await axios.get(`${config.denaUrl}/account/bonus`, { + headers: sessionHeaders(session), + validateStatus: () => true, + }); + if (res.status !== 200) { + return { h24: 0, all: 0, number: 0 }; + } + return res.data || { h24: 0, all: 0, number: 0 }; +} + +async function getBonusReport(session, { limit = 10, page = 1 } = {}) { + const res = await axios.get(`${config.denaUrl}/account/bonus/report`, { + headers: sessionHeaders(session), + params: { limit, page }, + validateStatus: () => true, + }); + if (res.status !== 200) { + return []; + } + return Array.isArray(res.data) ? res.data : []; +} + +async function getLevels(session) { + const res = await axios.get(`${config.denaUrl}/account/levels`, { + headers: sessionHeaders(session), + validateStatus: () => true, + }); + if (res.status !== 200) { + return null; + } + return res.data; +} + +async function getMemberTrades(session, { limit = 500, page = 1, timeFrom } = {}) { + const params = { limit, page, order_by: "desc" }; + if (timeFrom) params.time_from = timeFrom; + + const res = await axios.get(`${config.denaUrl}/market/trades`, { + headers: sessionHeaders(session), + params, + validateStatus: () => true, + }); + if (res.status !== 200) { + return []; + } + return Array.isArray(res.data) ? res.data : []; +} + +function formBody(fields) { + const params = new URLSearchParams(); + Object.entries(fields).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + params.append(key, String(value)); + } + }); + return params.toString(); +} + +async function postAccountForm(session, path, fields) { + const res = await axios.post(`${config.denaUrl}${path}`, formBody(fields), { + headers: { + ...sessionHeaders(session), + "Content-Type": "application/x-www-form-urlencoded", + }, + validateStatus: () => true, + }); + return res; +} + +async function createWithdraw( + session, + { currency, amount, rid, otp, paymentId, beneficiaryId, note } +) { + const fields = { + currency: String(currency || "").toLowerCase(), + amount, + note, + }; + + if (beneficiaryId) { + fields.beneficiary_id = beneficiaryId; + } else if (rid) { + fields.rid = rid; + } + + if (otp) fields.otp = otp; + if (paymentId) fields.payment_id = paymentId; + + return postAccountForm(session, "/account/withdraws", fields); +} + +async function getWithdrawById(session, id) { + const res = await axios.get(`${config.denaUrl}/account/withdraws`, { + headers: sessionHeaders(session), + params: { limit: 100, page: 1 }, + validateStatus: () => true, + }); + + if (res.status !== 200 || !Array.isArray(res.data)) { + return null; + } + + return res.data.find((item) => String(item.id) === String(id)) || null; +} + +async function confirmWithdraw(session, id, token) { + const body = formBody({ code: token, otp: token, token }); + + const attempts = [ + () => + axios.post(`${config.denaUrl}/account/withdraws/${id}/confirm`, body, { + headers: { + ...sessionHeaders(session), + "Content-Type": "application/x-www-form-urlencoded", + }, + validateStatus: () => true, + }), + () => + axios.put(`${config.denaUrl}/account/withdraws/${id}`, body, { + headers: { + ...sessionHeaders(session), + "Content-Type": "application/x-www-form-urlencoded", + }, + validateStatus: () => true, + }), + () => + axios.post(`${config.denaUrl}/account/withdraws/${id}/action`, body, { + headers: { + ...sessionHeaders(session), + "Content-Type": "application/x-www-form-urlencoded", + }, + validateStatus: () => true, + }), + () => + axios.post(`${config.denaUrl}/account/withdraws/confirm`, formBody({ id, otp: token }), { + headers: { + ...sessionHeaders(session), + "Content-Type": "application/x-www-form-urlencoded", + }, + validateStatus: () => true, + }), + ]; + + let lastRes = null; + for (const attempt of attempts) { + const res = await attempt(); + lastRes = res; + if (res.status === 200 || res.status === 201 || res.status === 204) { + return res; + } + } + + const current = await getWithdrawById(session, id); + if (current && !["prepared", "submitted"].includes(String(current.state).toLowerCase())) { + return { status: 200, data: current }; + } + + return lastRes || { status: 422, data: { error: "Confirm failed" } }; +} + +async function cancelWithdraw(session, id) { + const attempts = [ + () => + axios.delete(`${config.denaUrl}/account/withdraws/${id}`, { + headers: sessionHeaders(session), + validateStatus: () => true, + }), + () => + axios.post(`${config.denaUrl}/account/withdraws/${id}/cancel`, null, { + headers: sessionHeaders(session), + validateStatus: () => true, + }), + ]; + + for (const attempt of attempts) { + const res = await attempt(); + if (res.status === 200 || res.status === 201 || res.status === 204) { + return res; + } + } + + return attempts[0](); +} + +async function resendWithdrawEmail(session, id) { + const attempts = [ + () => + axios.post(`${config.denaUrl}/account/withdraws/${id}/resend`, null, { + headers: sessionHeaders(session), + validateStatus: () => true, + }), + () => + axios.post(`${config.denaUrl}/account/withdraws/${id}/resend_email`, null, { + headers: sessionHeaders(session), + validateStatus: () => true, + }), + ]; + + for (const attempt of attempts) { + const res = await attempt(); + if (res.status === 200 || res.status === 201 || res.status === 204) { + return res; + } + } + + return { status: 200, data: { success: true } }; +} + +async function validateWithdrawOtp(session, otp) { + return postAccountForm(session, "/account/otp", { + otp, + action: "withdraw", + }); +} + +async function createFiatDeposit(session, { amount, card, callbackUrl, currency = "irt" }) { + return postAccountForm(session, "/account/deposits/fiat", { + amount, + card, + callback_url: callbackUrl, + currency, + }); +} + +async function createFiatWithdraw(session, { amount, iban, currency = "irt", note }) { + return postAccountForm(session, "/account/withdraws/fiat", { + amount, + iban, + currency, + note, + }); +} + +async function confirmFiatWithdraw(session, { id, otp }) { + return postAccountForm(session, "/account/withdraws/confirm", { + id, + otp, + }); +} + +async function resendFiatWithdrawCode(session, email) { + return postAccountForm(session, "/account/otp/resend", { + data: email, + action: "withdraw-fiat", + }); +} + +async function getBeneficiaries(session, params = {}) { + const res = await axios.get(`${config.denaUrl}/account/beneficiaries`, { + headers: sessionHeaders(session), + params, + validateStatus: () => true, + }); + if (res.status !== 200) return []; + return Array.isArray(res.data) ? res.data : []; +} + +async function getBeneficiaryById(session, id) { + const res = await axios.get(`${config.denaUrl}/account/beneficiaries/${id}`, { + headers: sessionHeaders(session), + validateStatus: () => true, + }); + return res.status === 200 ? res.data : null; +} + +async function createBeneficiary(session, payload) { + const res = await axios.post(`${config.denaUrl}/account/beneficiaries`, payload, { + headers: sessionHeaders(session), + validateStatus: () => true, + }); + return res; +} + +async function activateBeneficiary(session, id, pin) { + const res = await axios.patch( + `${config.denaUrl}/account/beneficiaries/${id}/activate`, + { pin }, + { + headers: sessionHeaders(session), + validateStatus: () => true, + } + ); + return res; +} + +async function resendBeneficiaryPin(session, id) { + const res = await axios.patch(`${config.denaUrl}/account/beneficiaries/${id}/resend_pin`, null, { + headers: sessionHeaders(session), + validateStatus: () => true, + }); + return res; +} + +async function deleteBeneficiary(session, id) { + const res = await axios.delete(`${config.denaUrl}/account/beneficiaries/${id}`, { + headers: sessionHeaders(session), + validateStatus: () => true, + }); + return res; +} + +module.exports = { + getTickers, + getMarkets, + getCurrencies, + getBalances, + getOrderBook, + getOrders, + createOrder, + cancelOrder, + cancelAllOrders, + getKLine, + getPublicTrades, + getTradingFees, + getDepositAddress, + getDeposits, + getWithdrawals, + getBonus, + getBonusReport, + getLevels, + getMemberTrades, + createWithdraw, + confirmWithdraw, + cancelWithdraw, + resendWithdrawEmail, + getWithdrawById, + validateWithdrawOtp, + createFiatDeposit, + createFiatWithdraw, + confirmFiatWithdraw, + resendFiatWithdrawCode, + getBeneficiaries, + getBeneficiaryById, + createBeneficiary, + activateBeneficiary, + resendBeneficiaryPin, + deleteBeneficiary, +}; diff --git a/src/lib/jibitClient.js b/src/lib/jibitClient.js new file mode 100644 index 0000000..5a4fa8e --- /dev/null +++ b/src/lib/jibitClient.js @@ -0,0 +1,87 @@ +const axios = require("axios"); +const config = require("../config"); + +let cachedToken = null; +let tokenExpiresAt = 0; + +async function getAccessToken() { + if (cachedToken && Date.now() < tokenExpiresAt) { + return cachedToken; + } + + const res = await axios.post( + `${config.jibitApiUrl}/tokens/generate`, + { + apiKey: config.jibitApiKey, + secretKey: config.jibitApiSecret, + }, + { validateStatus: () => true } + ); + + if (res.status !== 200 || !res.data?.accessToken) { + const err = new Error("Jibit authentication failed"); + err.statusCode = 502; + throw err; + } + + cachedToken = res.data.accessToken; + tokenExpiresAt = Date.now() + 23 * 60 * 60 * 1000; + return cachedToken; +} + +async function jibitGet(path, params = {}) { + const token = await getAccessToken(); + const res = await axios.get(`${config.jibitApiUrl}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + params, + validateStatus: () => true, + }); + return res; +} + +function mapBankInfo(data) { + const bank = data?.bank || data?.ibanInfo?.bank || data?.cardInfo?.bank || {}; + return { + bankSwiftCode: bank.swiftCode || bank.code || bank.bankCode || "", + bankName: bank.name || bank.bankName || "", + iban: data?.iban || data?.ibanInfo?.iban || "", + cardNumber: data?.cardNumber || data?.number || "", + ownerName: data?.ownerName || data?.fullName || "", + }; +} + +async function getCardInfo(cardNumber) { + const res = await jibitGet("/cards", { number: cardNumber }); + if (res.status !== 200) { + const err = new Error("Unable to fetch card info"); + err.statusCode = res.status; + throw err; + } + return mapBankInfo(res.data || {}); +} + +async function getIbanInfo(iban) { + const res = await jibitGet("/ibans", { value: iban }); + if (res.status !== 200) { + const err = new Error("Unable to fetch IBAN info"); + err.statusCode = res.status; + throw err; + } + return mapBankInfo(res.data || {}); +} + +async function postalCodeToAddress(postalCode) { + const res = await jibitGet("/services/postalCode", { code: postalCode }).catch(() => null); + if (!res || res.status !== 200) { + return { address: "" }; + } + return { + address: res.data?.address || res.data?.result?.address || "", + }; +} + +module.exports = { + getCardInfo, + getIbanInfo, + postalCodeToAddress, +}; diff --git a/src/lib/locationsStore.js b/src/lib/locationsStore.js new file mode 100644 index 0000000..23b6b4b --- /dev/null +++ b/src/lib/locationsStore.js @@ -0,0 +1,55 @@ +const fs = require("fs"); +const path = require("path"); + +let cache = null; + +function loadLocations() { + if (cache) return cache; + + const filePath = + process.env.LOCATIONS_JSON || + path.join(__dirname, "..", "data", "locations.json"); + + if (!fs.existsSync(filePath)) { + cache = { provinces: [], cities: [] }; + return cache; + } + + try { + cache = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + cache = { provinces: [], cities: [] }; + } + + return cache; +} + +function listRegions() { + return loadLocations().provinces || []; +} + +function listCities(regionId) { + const id = Number(regionId); + return (loadLocations().cities || []).filter((city) => Number(city.provinceId) === id); +} + +function resolveCity(cityId) { + const id = Number(cityId); + const city = (loadLocations().cities || []).find((entry) => Number(entry.id) === id); + if (!city) return null; + + const province = (loadLocations().provinces || []).find( + (entry) => Number(entry.id) === Number(city.provinceId) + ); + + return { + cityName: city.name, + provinceName: province?.name || "", + }; +} + +module.exports = { + listRegions, + listCities, + resolveCity, +}; diff --git a/src/lib/mapActivity.js b/src/lib/mapActivity.js new file mode 100644 index 0000000..7e21db7 --- /dev/null +++ b/src/lib/mapActivity.js @@ -0,0 +1,55 @@ +function parseOsFamily(userAgent) { + if (!userAgent) return "Unknown"; + const ua = String(userAgent).toLowerCase(); + if (ua.includes("iphone") || ua.includes("ipad")) return "iOS"; + if (ua.includes("android")) return "Android"; + if (ua.includes("windows")) return "Windows"; + if (ua.includes("mac os") || ua.includes("macintosh")) return "macOS"; + if (ua.includes("linux")) return "Linux"; + return "Web"; +} + +function mapDalanActivity(item) { + return { + id: item.id, + ip: item.user_ip, + osFamily: parseOsFamily(item.user_agent), + created: item.created_at, + success: item.result === "succeed", + activity: item.action || item.topic || "", + }; +} + +function mapDalanActivities(items) { + return (items || []).map(mapDalanActivity); +} + +function dedupeDevices(activities) { + const seen = new Set(); + const devices = []; + for (const row of activities) { + const key = `${row.osFamily}|${row.ip}`; + if (seen.has(key)) continue; + seen.add(key); + devices.push(row); + } + return devices; +} + +const ACTIVITY_FILTER_OPTIONS = [ + { id: "session", name: "Session" }, + { id: "password", name: "Password" }, + { id: "otp", name: "OTP" }, + { id: "account", name: "Account" }, + { id: "create", name: "Create" }, + { id: "update", name: "Update" }, + { id: "read", name: "Read" }, + { id: "delete", name: "Delete" }, +]; + +module.exports = { + mapDalanActivity, + mapDalanActivities, + dedupeDevices, + ACTIVITY_FILTER_OPTIONS, +}; diff --git a/src/lib/mapAssetHistory.js b/src/lib/mapAssetHistory.js new file mode 100644 index 0000000..ed7799d --- /dev/null +++ b/src/lib/mapAssetHistory.js @@ -0,0 +1,36 @@ +const { normalizeCurrencyForShahoo } = require("./mapMarket"); + +function mapBalancesToAssetHistory(balances = []) { + const now = new Date().toISOString(); + return balances + .map((balance) => { + const currencyName = normalizeCurrencyForShahoo(balance.currency); + const availableBalance = parseFloat(balance.balance || 0); + const blockedBalance = parseFloat(balance.locked || 0); + const totalBalance = availableBalance + blockedBalance; + + return { + currencyName, + availableBalance, + blockedBalance, + totalBalance, + updated: now, + }; + }) + .filter((row) => row.totalBalance > 0); +} + +function mapBalancesToHistorySeries(balances = []) { + const snapshot = mapBalancesToAssetHistory(balances); + const total = snapshot.reduce((sum, row) => sum + row.totalBalance, 0); + + return snapshot.map((row) => ({ + ...row, + portfolioShare: total > 0 ? (row.totalBalance / total) * 100 : 0, + })); +} + +module.exports = { + mapBalancesToAssetHistory, + mapBalancesToHistorySeries, +}; diff --git a/src/lib/mapBeneficiary.js b/src/lib/mapBeneficiary.js new file mode 100644 index 0000000..9f9df5c --- /dev/null +++ b/src/lib/mapBeneficiary.js @@ -0,0 +1,41 @@ +function mapBeneficiaryToDestinationWallet(beneficiary, email = "") { + if (!beneficiary) return null; + + const data = beneficiary.data || {}; + const currencyName = String(beneficiary.currency || beneficiary.currency_id || "").toUpperCase(); + const active = String(beneficiary.state || "").toLowerCase() === "active"; + + return { + id: beneficiary.id, + currencyName, + name: beneficiary.name || "", + address: data.address || data.iban || "", + xrpTag: data.payment_id || data.tag || data.memo || "-", + inWhitelist: active, + username: email, + }; +} + +function mapDestinationWalletPayload(body = {}, email = "") { + const currency = String(body.currencyName || body.currency || "").toLowerCase(); + const xrpTag = body.xrpTag && body.xrpTag !== "-" ? body.xrpTag : undefined; + + const data = { + address: body.address, + }; + if (xrpTag) data.payment_id = xrpTag; + + return { + currency_id: currency, + name: body.name || body.address, + description: body.description || "", + data, + email: body.username || email, + inWhitelist: Boolean(body.inWhitelist), + }; +} + +module.exports = { + mapBeneficiaryToDestinationWallet, + mapDestinationWalletPayload, +}; diff --git a/src/lib/mapConstants.js b/src/lib/mapConstants.js new file mode 100644 index 0000000..8367188 --- /dev/null +++ b/src/lib/mapConstants.js @@ -0,0 +1,43 @@ +const { marketIdToShahooStatKeys } = require("./mapMarket"); +const { buildTradeFeeTiers } = require("./mapVip"); + +const DEFAULT_MIN_ORDER = { + valueMap: { + IRT: 100000, + USDT: 10, + CAD: 10, + }, +}; + +function buildConstantsFromMarkets(markets, tradingFees = []) { + const orderScaleMap = {}; + const marketOrderPriceScaleMap = {}; + + for (const market of markets || []) { + const base = String(market.base_unit || "").toUpperCase(); + const amountPrecision = Number(market.amount_precision); + if (base && Number.isFinite(amountPrecision)) { + orderScaleMap[base] = amountPrecision; + } + + const pricePrecision = Number(market.price_precision); + if (Number.isFinite(pricePrecision)) { + const keys = marketIdToShahooStatKeys(market.id); + keys.forEach((key) => { + marketOrderPriceScaleMap[key] = pricePrecision; + }); + } + } + + return { + orderScaleMap, + marketOrderPriceScaleMap, + minOrder: DEFAULT_MIN_ORDER, + withdrawFees: { irt: 0 }, + depositRoleLimits: {}, + withdrawRoleLimits: {}, + tradeFee: buildTradeFeeTiers(tradingFees), + }; +} + +module.exports = { buildConstantsFromMarkets, DEFAULT_MIN_ORDER }; diff --git a/src/lib/mapKyc.js b/src/lib/mapKyc.js new file mode 100644 index 0000000..501270e --- /dev/null +++ b/src/lib/mapKyc.js @@ -0,0 +1,123 @@ +const { mapShahooUser } = require("./mapUser"); + +function mapLabelStatus(labels, key) { + const label = (labels || []).find((entry) => entry.key === key); + if (!label) return "VOID"; + + const value = String(label.value || "").toLowerCase(); + if (value === "verified") return "DONE"; + if (value === "rejected") return "FAIL"; + if (["submitted", "pending", "processing"].includes(value)) return "PEND"; + return "VOID"; +} + +function mapTreasuryState(state) { + const value = String(state || "").toLowerCase(); + if (value === "verified" || value === "active") return "DONE"; + if (value === "rejected") return "FAIL"; + if (["submitted", "pending", "processing"].includes(value)) return "PEND"; + return "VOID"; +} + +function parseJibitSwift(raw) { + if (!raw) return ""; + try { + const data = typeof raw === "string" ? JSON.parse(raw) : raw; + return ( + data?.bankSwiftCode || + data?.bank?.swiftCode || + data?.bank?.code || + data?.ibanInfo?.bank?.swiftCode || + data?.cardInfo?.bank?.swiftCode || + "" + ); + } catch { + return ""; + } +} + +function mapTreasuryToBankInfo(treasury) { + const kind = String(treasury?.kind || "").toLowerCase(); + const type = kind === "iban" ? "IBAN" : "CARD"; + + return { + id: treasury.id, + number: treasury.data, + title: treasury.title || "", + type, + bankSwiftCode: parseJibitSwift(treasury.result), + confirmationStatus: mapTreasuryState(treasury.state), + cardNo: type === "CARD" ? treasury.data : undefined, + }; +} + +function findDocument(documents, docType) { + return (documents || []).find( + (doc) => String(doc.doc_type || "").toLowerCase() === String(docType).toLowerCase() + ); +} + +function buildKycUser(dalanUser, { documents = [], treasuries = [] } = {}) { + const profile = dalanUser?.profiles?.[0] || {}; + const labels = dalanUser?.labels || []; + const phone = + (dalanUser?.phones || []).find((entry) => entry.state === "verified")?.number || + (dalanUser?.phones || [])[0]?.number || + ""; + + let metadata = profile.metadata; + if (typeof metadata === "string") { + try { + metadata = JSON.parse(metadata); + } catch { + metadata = {}; + } + } + + const user = { + ...mapShahooUser(dalanUser), + firstName: profile.first_name || "", + lastName: profile.last_name || "", + nationalCode: profile.national_code || "", + dateOfBirth: profile.dob || "", + gender: metadata?.gender || "", + mobile: phone, + identityConfirmed: mapLabelStatus(labels, "profile"), + mobileConfirmed: mapLabelStatus(labels, "access_phone"), + bankCardConfirmed: mapLabelStatus(labels, "card"), + bankAccountConfirmed: mapLabelStatus(labels, "iban"), + addressConfirmed: mapLabelStatus(labels, "poa"), + selfieConfirmed: mapLabelStatus(labels, "selfie"), + cityId: profile.city?.id || profile.city_id || null, + address: profile.address || "", + zipCode: profile.postcode || "", + }; + + const identityDoc = findDocument(documents, "Identity card"); + const addressDoc = findDocument(documents, "Poa"); + const selfieDoc = findDocument(documents, "Selfie"); + + return { + user, + identity: identityDoc?.url || identityDoc?.upload || null, + address: addressDoc?.url || addressDoc?.upload || null, + selfie: selfieDoc?.url || selfieDoc?.upload || null, + bankInfo: (treasuries || []).map(mapTreasuryToBankInfo), + }; +} + +function mapBankCardPayload(body = {}) { + const type = String(body.type || "CARD").toUpperCase(); + return { + data: body.number, + kind: type === "IBAN" ? "iban" : "card", + title: body.title || "", + }; +} + +module.exports = { + buildKycUser, + mapBankCardPayload, + mapTreasuryToBankInfo, + mapLabelStatus, +}; diff --git a/src/lib/mapMarket.js b/src/lib/mapMarket.js new file mode 100644 index 0000000..0821a4a --- /dev/null +++ b/src/lib/mapMarket.js @@ -0,0 +1,132 @@ +/** @deprecated Prefer dynamic catalog from GET /api/1/markets/catalog */ +const SHAAHO_CURRENCIES = new Set([ + "BTC", + "ETH", + "LTC", + "USDT", + "XRP", + "BCH", + "LINK", + "AAVE", + "UNI", + "TRX", + "DAI", + "IRT", + "CAD", + "BNB", +]); + +/** + * Map OpenDAX/Dena market id (e.g. btccad, btcirt, btcusdt) to Shahoo statMap keys. + */ +function marketIdToShahooStatKeys(marketId) { + const id = String(marketId || "").toLowerCase(); + const match = id.match(/^([a-z0-9]+?)(cad|irt|usdt)$/); + if (!match) return []; + + const base = match[1].toUpperCase(); + const quote = match[2].toUpperCase(); + + return [`${base}_${quote}`]; +} + +function parseTickerPrice(entry) { + const raw = entry?.ticker?.last ?? entry?.last ?? "0"; + const price = parseFloat(raw); + return Number.isFinite(price) ? price : 0; +} + +function parseTickerOpen(entry) { + const raw = entry?.ticker?.open ?? entry?.open ?? "0"; + const price = parseFloat(raw); + return Number.isFinite(price) ? price : 0; +} + +function parseTickerField(entry, field) { + const raw = entry?.ticker?.[field] ?? entry?.[field] ?? "0"; + const value = parseFloat(raw); + return Number.isFinite(value) ? value : 0; +} + +function mapTickersToStatMap(tickers) { + const statMap = {}; + + for (const [marketId, entry] of Object.entries(tickers || {})) { + const keys = marketIdToShahooStatKeys(marketId); + const unitPrice = parseTickerPrice(entry); + const dayClose = parseTickerOpen(entry) || unitPrice; + const dayHigh = parseTickerField(entry, "high") || unitPrice; + const dayLow = parseTickerField(entry, "low") || unitPrice; + const volumeDst = parseTickerField(entry, "volume") || parseTickerField(entry, "amount"); + + keys.forEach((key) => { + statMap[key] = { + unitPrice, + market: key, + dayClose, + dayHigh, + dayLow, + volumeDst, + bestBuyPrice: unitPrice, + bestSellPrice: unitPrice, + }; + }); + } + + if (!statMap.USDT_IRT || !statMap.USDT_IRT.unitPrice) { + statMap.USDT_IRT = { unitPrice: 1, market: "USDT_IRT" }; + } + + statMap.IRT_IRT = { unitPrice: 1, market: "IRT_IRT" }; + + return statMap; +} + +function normalizeCurrencyForShahoo(currencyId) { + return String(currencyId || "").toUpperCase(); +} + +function mapDenaBalancesToWallets(balances) { + const merged = new Map(); + + (balances || []).forEach((b) => { + const currencyName = normalizeCurrencyForShahoo(b.currency); + if (!currencyName) return; + + const availableBalance = parseFloat(b.balance || 0); + const blockedBalance = parseFloat(b.locked || 0); + + if (merged.has(currencyName)) { + const existing = merged.get(currencyName); + existing.availableBalance += availableBalance; + existing.blockedBalance += blockedBalance; + existing.totalBalance = existing.availableBalance + existing.blockedBalance; + if (!existing.deposit_address && b.deposit_address) { + existing.deposit_address = b.deposit_address; + existing.address = b.deposit_address; + } + return; + } + + merged.set(currencyName, { + currencyName, + availableBalance, + blockedBalance, + totalBalance: availableBalance + blockedBalance, + currency: currencyName, + deposit_address: b.deposit_address || null, + address: b.deposit_address || b.address || null, + }); + }); + + return Array.from(merged.values()); +} + +module.exports = { + SHAAHO_CURRENCIES, + normalizeCurrencyForShahoo, + marketIdToShahooStatKeys, + mapTickersToStatMap, + mapDenaBalancesToWallets, +}; + \ No newline at end of file diff --git a/src/lib/mapMarketsCatalog.js b/src/lib/mapMarketsCatalog.js new file mode 100644 index 0000000..2ad2fe0 --- /dev/null +++ b/src/lib/mapMarketsCatalog.js @@ -0,0 +1,66 @@ +const ENABLED_STATES = new Set(["enabled", "online"]); + +const QUOTE_SORT = ["IRT", "USDT", "CAD"]; + +function buildMarketsCatalog(markets, currencies = []) { + const currencyMeta = {}; + (currencies || []).forEach((c) => { + const id = String(c.id || c.currency || "").toUpperCase(); + if (id) { + currencyMeta[id] = c; + } + }); + + const enabledMarkets = (markets || []).filter((m) => { + const state = String(m.state || "").toLowerCase(); + return !state || ENABLED_STATES.has(state); + }); + + const marketsByQuote = {}; + const catalogMarkets = []; + const quoteSet = new Set(); + + enabledMarkets.forEach((m) => { + const base = String(m.base_unit || "").toUpperCase(); + const quote = String(m.quote_unit || "").toUpperCase(); + if (!base || !quote) return; + + quoteSet.add(quote); + if (!marketsByQuote[quote]) { + marketsByQuote[quote] = []; + } + if (!marketsByQuote[quote].includes(base)) { + marketsByQuote[quote].push(base); + } + + catalogMarkets.push({ + marketId: String(m.id || "").toLowerCase(), + marketKey: `${base}_${quote}`, + base, + quote, + symbol: `${base}${quote}`, + route: `${base}-${quote}`, + enabled: true, + amountPrecision: Number(m.amount_precision), + pricePrecision: Number(m.price_precision), + }); + }); + + Object.keys(marketsByQuote).forEach((quote) => { + marketsByQuote[quote].sort(); + }); + + const quoteCurrencies = [ + ...QUOTE_SORT.filter((q) => quoteSet.has(q)), + ...[...quoteSet].filter((q) => !QUOTE_SORT.includes(q)).sort(), + ]; + + return { + quoteCurrencies, + marketsByQuote, + markets: catalogMarkets, + currencies: currencyMeta, + }; +} + +module.exports = { buildMarketsCatalog, QUOTE_SORT }; diff --git a/src/lib/mapNotification.js b/src/lib/mapNotification.js new file mode 100644 index 0000000..d7ca536 --- /dev/null +++ b/src/lib/mapNotification.js @@ -0,0 +1,87 @@ +const { normalizeCurrencyForShahoo } = require("./mapMarket"); +const { mapDepositStatus, mapWithdrawStatus } = require("./mapWithdrawal"); + +function parseTime(value) { + if (!value) return new Date().toISOString(); + const ms = Date.parse(value); + return Number.isFinite(ms) ? new Date(ms).toISOString() : String(value); +} + +function buildDepositNotification(deposit) { + const currency = normalizeCurrencyForShahoo(deposit.currency || deposit.currencyName); + const amount = parseFloat(deposit.amount) || 0; + const status = mapDepositStatus(deposit.state || deposit.status); + + return { + id: `deposit-${deposit.id}`, + text: `واریز ${amount} ${currency} — ${status}`, + created: parseTime(deposit.created_at || deposit.created), + status: "UNREAD", + _sortTime: Date.parse(deposit.created_at || deposit.created) || 0, + }; +} + +function buildWithdrawNotification(withdraw) { + const currency = normalizeCurrencyForShahoo(withdraw.currency || withdraw.currencyName); + const amount = parseFloat(withdraw.amount) || 0; + const status = mapWithdrawStatus(withdraw.state || withdraw.status); + + return { + id: `withdraw-${withdraw.id}`, + text: `برداشت ${amount} ${currency} — ${status}`, + created: parseTime(withdraw.created_at || withdraw.created), + status: "UNREAD", + _sortTime: Date.parse(withdraw.created_at || withdraw.created) || 0, + }; +} + +function buildActivityNotification(activity) { + const action = activity.action || activity.topic || "activity"; + const result = activity.result || activity.state || "succeed"; + + return { + id: `activity-${activity.id || `${action}-${activity.created_at}`}`, + text: `${action} (${result})`, + created: parseTime(activity.created_at || activity.created), + status: "UNREAD", + _sortTime: Date.parse(activity.created_at || activity.created) || 0, + }; +} + +function mergeNotifications(items) { + return items + .filter(Boolean) + .sort((a, b) => b._sortTime - a._sortTime) + .map(({ _sortTime, ...row }) => row); +} + +function applyNotificationPrefs(notifications, prefs = {}) { + const deleted = new Set((prefs.deletedNotificationIds || []).map(String)); + const read = new Set((prefs.readNotificationIds || []).map(String)); + + return notifications + .filter((row) => !deleted.has(String(row.id))) + .map((row) => ({ + ...row, + status: read.has(String(row.id)) ? "READ" : row.status, + })); +} + +function paginateNotifications(rows, pageSize, pageNumber) { + const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100)); + const page = Math.max(0, Number(pageNumber) || 0); + const start = page * limit; + return { + content: rows.slice(start, start + limit), + count: rows.length, + }; +} + +module.exports = { + buildDepositNotification, + buildWithdrawNotification, + buildActivityNotification, + mergeNotifications, + applyNotificationPrefs, + paginateNotifications, +}; diff --git a/src/lib/mapOhlcv.js b/src/lib/mapOhlcv.js new file mode 100644 index 0000000..6599451 --- /dev/null +++ b/src/lib/mapOhlcv.js @@ -0,0 +1,76 @@ +const { shahooMarketToDenaId } = require("./mapOrder"); + +const SHAAHO_PERIOD_TO_DENA = { + D1: 1440, + W1: 10080, + M1: 4320, + H1: 60, + H4: 240, +}; + +/** TradingView resolution → Dena/Peatio k-line period (minutes). + * Only periods that Influx/Peatio actually store — no fake remaps. */ +const TV_RESOLUTION_TO_DENA = { + 1: 1, + 5: 5, + 15: 15, + 30: 30, + 60: 60, + 120: 120, + 240: 240, + 360: 360, + 720: 720, + "1D": 1440, + "1W": 10080, + D: 1440, + d: 1440, + W: 10080, + "3d": 4320, + "3D": 4320, +}; + +function shahooPeriodToDena(period) { + const key = String(period || "D1").toUpperCase(); + if (SHAAHO_PERIOD_TO_DENA[key]) return SHAAHO_PERIOD_TO_DENA[key]; + const asNumber = Number(period); + return Number.isFinite(asNumber) && asNumber > 0 ? asNumber : 1440; +} + +function tradingViewResolutionToDena(resolution) { + const key = String(resolution || "60"); + if (TV_RESOLUTION_TO_DENA[key] !== undefined) return TV_RESOLUTION_TO_DENA[key]; + const asNumber = Number(resolution); + return Number.isFinite(asNumber) && asNumber > 0 ? asNumber : 60; +} + +function mapDenaKLineToShahooBar(point) { + if (!Array.isArray(point) || point.length < 6) return null; + + const [time, open, high, low, close, volume] = point; + const ts = Number(time); + + return { + time: ts, + open: parseFloat(open) || 0, + high: parseFloat(high) || 0, + low: parseFloat(low) || 0, + close: parseFloat(close) || 0, + volumeFrom: parseFloat(volume) || 0, + day: ts, + }; +} + +function mapDenaKLinesToShahoo(points) { + return (points || []) + .map(mapDenaKLineToShahooBar) + .filter(Boolean) + .sort((a, b) => a.time - b.time); +} + +module.exports = { + shahooPeriodToDena, + tradingViewResolutionToDena, + mapDenaKLineToShahooBar, + mapDenaKLinesToShahoo, + shahooMarketToDenaId, +}; diff --git a/src/lib/mapOrder.js b/src/lib/mapOrder.js new file mode 100644 index 0000000..f634419 --- /dev/null +++ b/src/lib/mapOrder.js @@ -0,0 +1,108 @@ +const { marketIdToShahooStatKeys } = require("./mapMarket"); + +/** Shahoo market key (BTC_IRT) → Dena market id (btcirt). */ +function shahooMarketToDenaId(shahooMarket) { + const [base, quote] = String(shahooMarket || "") + .toUpperCase() + .split("_"); + if (!base || !quote) return null; + if (quote === "IRT") return `${base.toLowerCase()}irt`; + if (quote === "USDT") return `${base.toLowerCase()}usdt`; + if (quote === "CAD") return `${base.toLowerCase()}cad`; + return `${base.toLowerCase()}${quote.toLowerCase()}`; +} + +function denaMarketToShahooMarket(denaMarketId) { + const keys = marketIdToShahooStatKeys(denaMarketId); + return keys[0] || String(denaMarketId || "").toUpperCase(); +} + +function mapDenaOrderBookLevel(entry) { + if (Array.isArray(entry)) { + const unitPrice = parseFloat(entry[0]) || 0; + const size = parseFloat(entry[1]) || 0; + return { unitPrice, size }; + } + + const unitPrice = + parseFloat(entry?.price ?? entry?.unitPrice ?? entry?.avg_price) || 0; + const size = + parseFloat( + entry?.remaining_volume ?? + entry?.origin_volume ?? + entry?.size ?? + entry?.amount ?? + entry?.volume + ) || 0; + + return { unitPrice, size }; +} + +function mapDenaOrderBook(data, limit) { + const max = limit ? Number(limit) : undefined; + + const toLevels = (rows) => + (rows || []) + .map(mapDenaOrderBookLevel) + .filter((row) => row.unitPrice > 0 && row.size > 0); + + const sells = toLevels(data?.asks).slice(0, max); + const buys = toLevels(data?.bids).slice(0, max); + + return { sells, buys }; +} + +function mapDenaOrderToShahoo(order) { + const marketName = denaMarketToShahooMarket(order.market); + const baseCurrency = marketName.split("_")[0]; + + return { + id: order.id, + uuid: order.uuid, + created: order.created_at, + currencyName: baseCurrency, + marketName, + type: String(order.ord_type || "limit").toUpperCase(), + side: String(order.side || "").toUpperCase(), + unitPrice: parseFloat(order.price) || 0, + size: parseFloat(order.origin_volume) || 0, + remainedSize: parseFloat(order.remaining_volume) || 0, + fee: 0, + state: order.state, + }; +} + +function mapShahooOrderToDena(body) { + const market = shahooMarketToDenaId(body.marketName); + if (!market) { + const err = new Error("Invalid market"); + err.statusCode = 400; + throw err; + } + + return { + market, + side: String(body.side || "").toLowerCase(), + volume: String(body.size), + ord_type: String(body.type || "limit").toLowerCase(), + price: String(body.unitPrice), + }; +} + +function denaErrorMessage(data) { + if (!data) return "Request failed"; + if (Array.isArray(data.errors) && data.errors.length) { + return String(data.errors[0]); + } + if (typeof data.error === "string") return data.error; + return "Request failed"; +} + +module.exports = { + shahooMarketToDenaId, + denaMarketToShahooMarket, + mapDenaOrderBook, + mapDenaOrderToShahoo, + mapShahooOrderToDena, + denaErrorMessage, +}; diff --git a/src/lib/mapOtp.js b/src/lib/mapOtp.js new file mode 100644 index 0000000..a5070b7 --- /dev/null +++ b/src/lib/mapOtp.js @@ -0,0 +1,25 @@ +function extractSecretFromOtpUrl(value) { + const text = String(value || ""); + const match = text.match(/[?&]secret=([^&]+)/i); + return match ? decodeURIComponent(match[1]) : ""; +} + +function mapQrCodeResponse(data) { + const barcode = + data?.barcode || + data?.url || + data?.qr_code || + data?.qrCodeLink || + ""; + const secret = + data?.secret || + extractSecretFromOtpUrl(barcode) || + extractSecretFromOtpUrl(data?.url); + + return { + secret, + qrCodeLink: barcode || data?.url || "", + }; +} + +module.exports = { mapQrCodeResponse, extractSecretFromOtpUrl }; diff --git a/src/lib/mapReferral.js b/src/lib/mapReferral.js new file mode 100644 index 0000000..aefd74f --- /dev/null +++ b/src/lib/mapReferral.js @@ -0,0 +1,35 @@ +function mapBonusReportRow(item) { + return { + created: item.date || item.created_at, + amount: parseFloat(item.amount) || 0, + invitedAccount: item.uid || item.bonus_member_id || "", + }; +} + +function buildReferralLink(publicUrl, uid) { + const base = String(publicUrl || "").replace(/\/$/, ""); + return `${base}/signup?refid=${encodeURIComponent(uid)}`; +} + +function mapReferralResponse({ uid, publicUrl, bonus, report }) { + const stats = { + number: Number(bonus?.number) || 0, + h24: parseFloat(bonus?.h24) || 0, + all: parseFloat(bonus?.all) || 0, + }; + const commissionHistory = (report || []).map(mapBonusReportRow); + + return { + refCode: uid, + refLink: buildReferralLink(publicUrl, uid), + ...stats, + stats, + commissionHistory, + }; +} + +module.exports = { + mapReferralResponse, + mapBonusReportRow, + buildReferralLink, +}; diff --git a/src/lib/mapTrade.js b/src/lib/mapTrade.js new file mode 100644 index 0000000..b5612e9 --- /dev/null +++ b/src/lib/mapTrade.js @@ -0,0 +1,23 @@ +function parseTradeTime(createdAt) { + if (!createdAt) return Math.floor(Date.now() / 1000); + const ms = Date.parse(createdAt); + return Number.isFinite(ms) ? Math.floor(ms / 1000) : Math.floor(Date.now() / 1000); +} + +function mapDenaTradeToShahoo(trade) { + return { + id: trade.id, + unitPrice: parseFloat(trade.price) || 0, + amount: parseFloat(trade.amount) || 0, + total: parseFloat(trade.total) || 0, + time: parseTradeTime(trade.created_at), + side: trade.taker_type || trade.side, + market: trade.market, + }; +} + +function mapDenaTradesToShahoo(trades) { + return (trades || []).map(mapDenaTradeToShahoo); +} + +module.exports = { mapDenaTradeToShahoo, mapDenaTradesToShahoo }; diff --git a/src/lib/mapTransaction.js b/src/lib/mapTransaction.js new file mode 100644 index 0000000..3f1503b --- /dev/null +++ b/src/lib/mapTransaction.js @@ -0,0 +1,174 @@ +const { normalizeCurrencyForShahoo } = require("./mapMarket"); +const { mapDepositStatus, mapWithdrawStatus } = require("./mapWithdrawal"); + +const FAIL_STATUSES = new Set(["FAILED", "CANCELLED"]); + +function parseTime(value) { + if (!value) return new Date(0).toISOString(); + const ms = Date.parse(value); + return Number.isFinite(ms) ? new Date(ms).toISOString() : String(value); +} + +function tradeSideLabel(takerType) { + const side = String(takerType || "").toLowerCase(); + if (side === "buy") return "BUY"; + if (side === "sell") return "SELL"; + return "TRADE"; +} + +function isFiatTrade(marketId, takerType) { + const market = String(marketId || "").toLowerCase(); + const quoteIsFiat = market.endsWith("cad") || market.endsWith("irt"); + const side = String(takerType || "").toLowerCase(); + return quoteIsFiat && side === "buy"; +} + +function mapDenaTradeToTransaction(trade) { + const market = String(trade.market || "").toLowerCase(); + const baseMatch = market.match(/^([a-z0-9]+?)(cad|irt|usdt)$/); + const currencyName = normalizeCurrencyForShahoo( + baseMatch ? baseMatch[1].toUpperCase() : trade.currency + ); + + return { + id: `t-${trade.id}`, + created: parseTime(trade.created_at || trade.created), + transactionType: "TRADE", + currencyName, + amount: parseFloat(trade.amount) || 0, + fee: parseFloat(trade.fee || trade.fee_amount || 0) || 0, + description: `${tradeSideLabel(trade.taker_type || trade.side)} ${currencyName}`, + isFiat: isFiatTrade(market, trade.taker_type || trade.side), + status: "DONE", + _sortTime: Date.parse(trade.created_at || trade.created) || 0, + }; +} + +function mapDenaDepositToTransaction(deposit) { + const status = mapDepositStatus(deposit.state || deposit.status); + const currencyName = normalizeCurrencyForShahoo(deposit.currency || deposit.currencyName); + + return { + id: `d-${deposit.id}`, + created: parseTime(deposit.created_at || deposit.created), + transactionType: "DEPOSIT", + currencyName, + amount: parseFloat(deposit.amount) || 0, + fee: parseFloat(deposit.fee || 0) || 0, + description: deposit.txid || deposit.blockchain_txid || "Deposit", + isFiat: currencyName === "IRT" || currencyName === "CAD", + status, + _sortTime: Date.parse(deposit.created_at || deposit.created) || 0, + }; +} + +function mapDenaWithdrawToTransaction(withdraw) { + const status = mapWithdrawStatus(withdraw.state || withdraw.status); + const currencyName = normalizeCurrencyForShahoo(withdraw.currency || withdraw.currencyName); + const mappedStatus = status === "INITIAL_CONFIRMATION" ? "OPEN" : status; + + return { + id: `w-${withdraw.id}`, + created: parseTime(withdraw.created_at || withdraw.created), + transactionType: "WITHDRAWAL", + currencyName, + amount: parseFloat(withdraw.amount) || 0, + fee: parseFloat(withdraw.fee || 0) || 0, + description: withdraw.rid || withdraw.blockchain_txid || "Withdrawal", + isFiat: currencyName === "IRT" || currencyName === "CAD", + status: mappedStatus, + _sortTime: Date.parse(withdraw.created_at || withdraw.created) || 0, + }; +} + +function mergeTransactions({ trades = [], deposits = [], withdrawals = [] }) { + return [...trades, ...deposits, ...withdrawals] + .filter(Boolean) + .sort((a, b) => b._sortTime - a._sortTime) + .map(({ _sortTime, ...row }) => row); +} + +function parseTypeFilter(typeParam) { + if (!typeParam) return null; + const values = String(typeParam) + .split(",") + .map((item) => item.trim().toUpperCase()) + .filter(Boolean); + return values.length ? values : null; +} + +function matchesTypeFilter(row, types) { + if (!types || !types.length) return true; + + return types.some((type) => { + if (type === "TRADE") return row.transactionType === "TRADE"; + if (type === "BUY") return row.transactionType === "TRADE" && row.isFiat; + if (type === "SELL") return row.transactionType === "TRADE" && !row.isFiat; + return row.transactionType === type; + }); +} + +function applyTransactionFilters(rows, query = {}) { + let result = rows; + + if (query.currencyName) { + const currency = String(query.currencyName).toUpperCase(); + result = result.filter((row) => row.currencyName === currency); + } + + const types = parseTypeFilter(query.type); + if (types) { + result = result.filter((row) => matchesTypeFilter(row, types)); + } + + if (query.from) { + const fromMs = Date.parse(query.from); + if (Number.isFinite(fromMs)) { + result = result.filter((row) => Date.parse(row.created) >= fromMs); + } + } + + if (query.to) { + const toMs = Date.parse(query.to); + if (Number.isFinite(toMs)) { + result = result.filter((row) => Date.parse(row.created) <= toMs); + } + } + + return result; +} + +function splitSuccessAndFail(rows) { + const success = []; + const fail = []; + + rows.forEach((row) => { + if (FAIL_STATUSES.has(row.status)) { + fail.push(row); + } else { + success.push(row); + } + }); + + return { success, fail }; +} + +function paginateRows(rows, pageSize, pageNumber) { + const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100)); + const page = Math.max(0, Number(pageNumber) || 0); + const start = page * limit; + return { + content: rows.slice(start, start + limit), + count: rows.length, + }; +} + +module.exports = { + mapDenaTradeToTransaction, + mapDenaDepositToTransaction, + mapDenaWithdrawToTransaction, + mergeTransactions, + applyTransactionFilters, + splitSuccessAndFail, + paginateRows, +}; diff --git a/src/lib/mapUser.js b/src/lib/mapUser.js new file mode 100644 index 0000000..80a68f6 --- /dev/null +++ b/src/lib/mapUser.js @@ -0,0 +1,40 @@ +function mapKycRole(dalanUser) { + const level = dalanUser?.level ?? 0; + if (level >= 2) return "TWO"; + if (level >= 1) return "ONE"; + return "ZERO"; +} + +function mapShahooUser(dalanUser, overrides = {}) { + const profile = dalanUser?.profiles?.[0] || {}; + const phone = dalanUser?.phones?.[0]?.number || ""; + + return { + id: dalanUser?.uid, + email: dalanUser?.email, + firstName: profile.first_name || "", + lastName: profile.last_name || "", + mobile: phone, + role: mapKycRole(dalanUser), + uid: dalanUser?.uid, + level: dalanUser?.level, + state: dalanUser?.state, + setting: { + theme: overrides.theme || "LIGHT", + layout: overrides.setting?.layout || overrides.layout || "template2", + isUsing2FA: Boolean(dalanUser?.otp), + whitelistEnable: Boolean(overrides.setting?.whitelistEnable ?? overrides.whitelistEnable), + hasDepositYet: false, + favoriteCurrency: [], + notificationSound: false, + ...(overrides.setting || {}), + }, + ...(overrides.extra || {}), + }; +} + +function mapCurrentUserSummary(dalanUser, overrides = {}) { + return mapShahooUser(dalanUser, overrides); +} + +module.exports = { mapShahooUser, mapCurrentUserSummary, mapKycRole }; diff --git a/src/lib/mapVip.js b/src/lib/mapVip.js new file mode 100644 index 0000000..6bd3a99 --- /dev/null +++ b/src/lib/mapVip.js @@ -0,0 +1,108 @@ +const config = require("../config"); + +const VIP_GROUPS = ["vip-0", "vip-1", "vip-2", "vip-3", "vip-4"]; + +function feePercent(value) { + const n = parseFloat(value); + if (!Number.isFinite(n)) return 0; + return n * 100; +} + +function levelIndex(levelName) { + const idx = VIP_GROUPS.indexOf(String(levelName || "vip-0").toLowerCase()); + return idx >= 0 ? idx : 0; +} + +function buildTradeFeeTiers(tradingFees) { + const byGroup = {}; + for (const fee of tradingFees || []) { + const group = String(fee.group || "").toLowerCase(); + if (group) byGroup[group] = fee; + } + + const thresholds = config.vipThresholds; + const tradeFeeMonthVolTh = [ + thresholds.vip0, + thresholds.vip1, + thresholds.vip2, + thresholds.vip3, + thresholds.vip4, + thresholds.vipMax, + ]; + + const tradeTakerFees = VIP_GROUPS.map((group) => { + const fee = byGroup[group] || byGroup.any; + return feePercent(fee?.taker); + }); + + const tradeMakerFees = VIP_GROUPS.map((group) => { + const fee = byGroup[group] || byGroup.any; + return feePercent(fee?.maker); + }); + + return { + tradeFeeMonthVolTh, + tradeTakerFees, + tradeMakerFees, + }; +} + +function buildLevelsDetail(tiers, currentIdx) { + return VIP_GROUPS.map((name, i) => { + const minVolume = tiers.tradeFeeMonthVolTh[i] ?? 0; + const maxVolume = + i < VIP_GROUPS.length - 1 ? tiers.tradeFeeMonthVolTh[i + 1] : null; + + return { + name, + levelNumber: i, + minVolume, + maxVolume, + takerFee: tiers.tradeTakerFees[i], + makerFee: tiers.tradeMakerFees[i], + isCurrent: i === currentIdx, + }; + }); +} + +function mapLimitations({ levels, tradeVolume, tradingFees }) { + const tiers = buildTradeFeeTiers(tradingFees); + const current = levels?.current || {}; + const next = levels?.next || {}; + const idx = levelIndex(current.name); + + let tradeFee = feePercent(current.taker_fee ?? current.taker); + let makerFee = feePercent(current.maker_fee ?? current.maker); + + if (!tradeFee) tradeFee = tiers.tradeTakerFees[idx] || tiers.tradeTakerFees[0] || 0.35; + if (!makerFee) makerFee = tiers.tradeMakerFees[idx] || tiers.tradeMakerFees[0] || 0.35; + + const currentLevelMin = parseFloat(current.min) || tiers.tradeFeeMonthVolTh[idx] || 0; + const nextLevelMin = + parseFloat(next.min) || + tiers.tradeFeeMonthVolTh[idx + 1] || + tiers.tradeFeeMonthVolTh[tiers.tradeFeeMonthVolTh.length - 1] || + 0; + + return { + tradeVolume: parseFloat(tradeVolume) || 0, + tradeFee, + makerFee, + currentLevel: current.name || VIP_GROUPS[idx], + nextLevel: next.name || VIP_GROUPS[idx + 1] || "", + currentLevelMin, + nextLevelMin, + levelIndex: idx, + levelsDetail: buildLevelsDetail(tiers, idx), + ...tiers, + }; +} + +module.exports = { + buildTradeFeeTiers, + mapLimitations, + buildLevelsDetail, + feePercent, + levelIndex, + VIP_GROUPS, +}; diff --git a/src/lib/mapWithdrawal.js b/src/lib/mapWithdrawal.js new file mode 100644 index 0000000..d074e66 --- /dev/null +++ b/src/lib/mapWithdrawal.js @@ -0,0 +1,79 @@ +const WITHDRAW_STATE_MAP = { + prepared: "INITIAL_CONFIRMATION", + submitted: "INITIAL_CONFIRMATION", + accepted: "PENDING", + processing: "PENDING", + confirming: "PENDING", + pending: "PENDING", + succeed: "DONE", + done: "DONE", + completed: "DONE", + canceled: "CANCELLED", + cancelled: "CANCELLED", + rejected: "FAILED", + failed: "FAILED", + errored: "FAILED", +}; + +const DEPOSIT_STATE_MAP = { + submitted: "PENDING", + processing: "PENDING", + accepted: "PENDING", + fee_processing: "PENDING", + fee_processed: "PENDING", + fee_collected: "PENDING", + collected: "DONE", + done: "DONE", + succeed: "DONE", + skipped: "CANCELLED", + canceled: "CANCELLED", + cancelled: "CANCELLED", + rejected: "FAILED", + failed: "FAILED", + errored: "FAILED", +}; + +function mapWithdrawStatus(state) { + const key = String(state || "").toLowerCase(); + return WITHDRAW_STATE_MAP[key] || "PENDING"; +} + +function mapDepositStatus(state) { + const key = String(state || "").toLowerCase(); + return DEPOSIT_STATE_MAP[key] || "PENDING"; +} + +function mapDenaWithdrawal(item) { + if (!item) return null; + + return { + id: item.id, + currencyName: String(item.currency || item.currencyName || "").toUpperCase(), + amount: parseFloat(item.amount) || 0, + fee: parseFloat(item.fee) || 0, + receiverAddress: item.rid || item.receiverAddress || item.destination || "", + txHash: item.blockchain_txid || item.txid || item.txHash || "", + status: mapWithdrawStatus(item.state || item.status), + created: item.created_at || item.created || item.date, + }; +} + +function mapDenaDeposit(item) { + if (!item) return null; + + return { + id: item.id, + currencyName: String(item.currency || item.currencyName || "").toUpperCase(), + amount: parseFloat(item.amount) || 0, + txHash: item.txid || item.blockchain_txid || item.txHash || "", + status: mapDepositStatus(item.state || item.status), + created: item.created_at || item.created || item.date, + }; +} + +module.exports = { + mapDenaWithdrawal, + mapDenaDeposit, + mapWithdrawStatus, + mapDepositStatus, +}; diff --git a/src/lib/phoneUtils.js b/src/lib/phoneUtils.js new file mode 100644 index 0000000..0594110 --- /dev/null +++ b/src/lib/phoneUtils.js @@ -0,0 +1,15 @@ +function digitsOnly(value) { + return String(value || "").replace(/\D/g, ""); +} + +/** Shahoo `(0912…)` / `0912…` → Dalan `+98912…` */ +function normalizeIranPhone(phone) { + let p = digitsOnly(phone); + if (!p) return ""; + if (p.startsWith("98")) return `+${p}`; + if (p.startsWith("0")) return `+98${p.slice(1)}`; + if (p.startsWith("9")) return `+98${p}`; + return `+${p}`; +} + +module.exports = { normalizeIranPhone, digitsOnly }; diff --git a/src/lib/sessionStore.js b/src/lib/sessionStore.js new file mode 100644 index 0000000..17bf0f3 --- /dev/null +++ b/src/lib/sessionStore.js @@ -0,0 +1,67 @@ +const Redis = require("ioredis"); + +const SESSION_TTL_SECONDS = Number(process.env.SESSION_TTL_SECONDS || 86400); +const memory = new Map(); + +let redis = null; +if (process.env.REDIS_URL) { + redis = new Redis(process.env.REDIS_URL, { + maxRetriesPerRequest: 1, + enableReadyCheck: true, + lazyConnect: true, + }); + redis.on("error", (err) => { + console.error("Shahoo BFF Redis session error:", err.message); + }); + redis.connect().catch((err) => { + console.error("Shahoo BFF Redis connect failed:", err.message); + redis = null; + }); +} + +function sessionKey(token) { + return `shahoo:bff:session:${token}`; +} + +function create(session) { + memory.set(session.token, session); + if (redis) { + redis + .set(sessionKey(session.token), JSON.stringify(session), "EX", SESSION_TTL_SECONDS) + .catch((err) => { + console.error("Shahoo BFF session persist failed:", err.message); + }); + } + return session; +} + +async function get(token) { + if (!token) return null; + + const cached = memory.get(token); + if (cached) return cached; + + if (!redis) return null; + + try { + const raw = await redis.get(sessionKey(token)); + if (!raw) return null; + const session = JSON.parse(raw); + memory.set(token, session); + return session; + } catch (err) { + console.error("Shahoo BFF session load failed:", err.message); + return null; + } +} + +function remove(token) { + memory.delete(token); + if (redis) { + redis.del(sessionKey(token)).catch((err) => { + console.error("Shahoo BFF session delete failed:", err.message); + }); + } +} + +module.exports = { create, get, remove }; diff --git a/src/lib/shahooResponse.js b/src/lib/shahooResponse.js new file mode 100644 index 0000000..9473069 --- /dev/null +++ b/src/lib/shahooResponse.js @@ -0,0 +1,13 @@ +function ok(content, statusCode = 200) { + return { statusCode, content }; +} + +function okPaged(content, count, statusCode = 200) { + return { statusCode, content, count }; +} + +function fail(message, statusCode = 400) { + return { statusCode, message, content: null }; +} + +module.exports = { ok, okPaged, fail }; diff --git a/src/lib/upstreamHeaders.js b/src/lib/upstreamHeaders.js new file mode 100644 index 0000000..de7e545 --- /dev/null +++ b/src/lib/upstreamHeaders.js @@ -0,0 +1,22 @@ +const config = require("../config"); + +function upstreamHeaders(extra = {}) { + const headers = { ...extra }; + if (config.upstreamHost) { + headers.Host = config.upstreamHost; + } + return headers; +} + +function sessionHeaders(session, extra = {}) { + const headers = upstreamHeaders(extra); + if (session?.cookies) { + headers.Cookie = session.cookies; + } + if (session?.csrfToken) { + headers["X-CSRF-Token"] = session.csrfToken; + } + return headers; +} + +module.exports = { upstreamHeaders, sessionHeaders }; diff --git a/src/lib/userPrefs.js b/src/lib/userPrefs.js new file mode 100644 index 0000000..d3903ac --- /dev/null +++ b/src/lib/userPrefs.js @@ -0,0 +1,114 @@ +const dalanClient = require("./dalanClient"); + +const DEFAULTS = { + favoriteCurrencies: [], + favoriteMarkets: [], + priceAlerts: [], + hiddenTransactionIds: [], + readNotificationIds: [], + deletedNotificationIds: [], +}; + +function parseDalanData(raw) { + if (!raw) return {}; + if (typeof raw === "object") return raw; + try { + return JSON.parse(raw); + } catch { + return {}; + } +} + +function normalizeCurrencyCode(value) { + return String(value || "").toUpperCase(); +} + +function extractShahooPrefs(data) { + const root = parseDalanData(data); + const shahoo = root.shahoo && typeof root.shahoo === "object" ? root.shahoo : root; + + return { + favoriteCurrencies: Array.isArray(shahoo.favoriteCurrencies) + ? shahoo.favoriteCurrencies.map(normalizeCurrencyCode) + : [], + favoriteMarkets: Array.isArray(shahoo.favoriteMarkets) + ? shahoo.favoriteMarkets.map(normalizeCurrencyCode) + : [], + priceAlerts: Array.isArray(shahoo.priceAlerts) ? shahoo.priceAlerts : [], + hiddenTransactionIds: Array.isArray(shahoo.hiddenTransactionIds) + ? shahoo.hiddenTransactionIds.map(String) + : [], + readNotificationIds: Array.isArray(shahoo.readNotificationIds) + ? shahoo.readNotificationIds.map(String) + : [], + deletedNotificationIds: Array.isArray(shahoo.deletedNotificationIds) + ? shahoo.deletedNotificationIds.map(String) + : [], + }; +} + +async function getPrefs(session) { + if (session.shahooPrefs) { + return session.shahooPrefs; + } + + if (!session.dalanUser) { + try { + await dalanClient.getMe(session); + } catch { + session.shahooPrefs = { ...DEFAULTS }; + return session.shahooPrefs; + } + } + + session.shahooPrefs = extractShahooPrefs(session.dalanUser?.data); + return session.shahooPrefs; +} + +async function savePrefs(session, prefs) { + session.shahooPrefs = prefs; + + try { + const user = session.dalanUser || (await dalanClient.getMe(session)); + const root = parseDalanData(user.data); + root.shahoo = prefs; + + const payload = + typeof user.data === "string" ? { data: JSON.stringify(root) } : { data: root }; + + const result = await dalanClient.resourcePut(session, "/resource/users/me", payload); + if (result.status === 200 && result.data) { + session.dalanUser = result.data; + } + } catch { + /* session-only fallback */ + } + + return prefs; +} + +async function updatePrefs(session, patch) { + const current = await getPrefs(session); + const next = { + ...current, + ...patch, + }; + return savePrefs(session, next); +} + +function nextAlertId(alerts) { + const maxId = (alerts || []).reduce((max, row) => { + const id = Number(row.id) || 0; + return id > max ? id : max; + }, 0); + return maxId + 1; +} + +module.exports = { + DEFAULTS, + normalizeCurrencyCode, + getPrefs, + savePrefs, + updatePrefs, + nextAlertId, +}; diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..27ec1e3 --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,37 @@ +const sessionStore = require("../lib/sessionStore"); + +function extractToken(req) { + const auth = String(req.headers.authorization || "").trim(); + if (!auth) return ""; + if (auth.toLowerCase().startsWith("bearer ")) { + return auth.slice(7).trim(); + } + return auth; +} + +function requireSession(req, res, next) { + const token = extractToken(req); + sessionStore + .get(token) + .then((session) => { + if (!session) { + return res.status(401).json({ + statusCode: 401, + message: "Unauthorized", + content: null, + }); + } + req.session = session; + req.bffToken = token; + next(); + }) + .catch(() => + res.status(401).json({ + statusCode: 401, + message: "Unauthorized", + content: null, + }) + ); +} + +module.exports = { extractToken, requireSession }; diff --git a/src/middleware/upload.js b/src/middleware/upload.js new file mode 100644 index 0000000..2276456 --- /dev/null +++ b/src/middleware/upload.js @@ -0,0 +1,8 @@ +const multer = require("multer"); + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, +}); + +module.exports = { upload }; diff --git a/src/routes/alerts.js b/src/routes/alerts.js new file mode 100644 index 0000000..a96b9f5 --- /dev/null +++ b/src/routes/alerts.js @@ -0,0 +1,94 @@ +const express = require("express"); +const { getPrefs, updatePrefs, nextAlertId } = require("../lib/userPrefs"); +const { ok, okPaged, fail } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +function parsePage(pageSize, pageNumber) { + const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100)); + const page = Math.max(0, Number(pageNumber) || 0); + return { limit, page }; +} + +function alertSide(crossType) { + return String(crossType || "").toUpperCase() === "DOWN" ? "sell" : "buy"; +} + +router.post("/", requireSession, async (req, res) => { + try { + const { crossType, marketName, price } = req.body || {}; + if (!marketName || price == null || !crossType) { + return res.status(422).json(fail("marketName, crossType and price are required", 422)); + } + + const prefs = await getPrefs(req.session); + const now = new Date().toISOString(); + const alert = { + id: nextAlertId(prefs.priceAlerts), + marketName: String(marketName).toUpperCase(), + crossType: String(crossType).toUpperCase(), + price: parseFloat(price) || 0, + side: alertSide(crossType), + favorite: false, + updated: now, + created: now, + }; + + await updatePrefs(req.session, { + priceAlerts: [...(prefs.priceAlerts || []), alert], + }); + + return res.json({ + statusCode: 200, + message: "Alert created", + content: alert, + }); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.get("/self/:pageSize/:pageNumber", requireSession, async (req, res) => { + try { + const prefs = await getPrefs(req.session); + const alerts = [...(prefs.priceAlerts || [])].sort( + (a, b) => Date.parse(b.updated || b.created) - Date.parse(a.updated || a.created) + ); + const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber); + const start = page * limit; + const content = alerts.slice(start, start + limit); + return res.json(okPaged(content, alerts.length)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +router.delete("/:id", requireSession, async (req, res) => { + try { + const id = Number(req.params.id); + const prefs = await getPrefs(req.session); + const nextAlerts = (prefs.priceAlerts || []).filter((row) => Number(row.id) !== id); + await updatePrefs(req.session, { priceAlerts: nextAlerts }); + return res.json({ statusCode: 200, message: "Alert deleted", content: null }); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.put("/delete", requireSession, async (req, res) => { + try { + const ids = (Array.isArray(req.body) ? req.body : []) + .map((row) => Number(row?.id)) + .filter((id) => Number.isFinite(id)); + const prefs = await getPrefs(req.session); + const idSet = new Set(ids); + const nextAlerts = (prefs.priceAlerts || []).filter((row) => !idSet.has(Number(row.id))); + await updatePrefs(req.session, { priceAlerts: nextAlerts }); + return res.json({ statusCode: 200, message: "Alerts deleted", content: null }); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +module.exports = router; diff --git a/src/routes/api.js b/src/routes/api.js new file mode 100644 index 0000000..561f995 --- /dev/null +++ b/src/routes/api.js @@ -0,0 +1,69 @@ +const express = require("express"); +const usersRouter = require("./users"); +const ohlcvsRouter = require("./ohlcvs"); +const constantsRouter = require("./constants"); +const walletsRouter = require("./wallets"); +const ordersRouter = require("./orders"); +const doneOrdersRouter = require("./doneOrders"); +const tradesRouter = require("./trades"); +const destinationWalletsRouter = require("./destinationWallets"); +const withdrawalsRouter = require("./withdrawals"); +const depositsRouter = require("./deposits"); +const notificationsRouter = require("./notifications"); +const kycUsersRouter = require("./kycUsers"); +const otpRouter = require("./otp"); +const jibitRouter = require("./jibit"); +const bankRouter = require("./bank"); +const locationsRouter = require("./locations"); +const ttsRouter = require("./tts"); +const irtpaysRouter = require("./irtpays"); +const marketRouter = require("./market"); +const marketsRouter = require("./markets"); +const alertsRouter = require("./alerts"); +const transactionsRouter = require("./transactions"); +const assetHistoryRouter = require("./assetHistory"); +const apiKeysRouter = require("./apiKeys"); +const docsRouter = require("./docs"); + +const router = express.Router(); + +router.get("/health", (_req, res) => { + res.json({ status: "ok", service: "shahoo-bff" }); +}); + +router.use("/docs", docsRouter); +router.use("/users/apiKeys", apiKeysRouter); +router.use("/users", usersRouter); +router.use("/users", kycUsersRouter); +router.use("/users/notifications", notificationsRouter); +router.use("/otp", otpRouter); +router.use("/jibit", jibitRouter); +router.use("/bank", bankRouter); +router.use("/locations", locationsRouter); +router.use("/tts", ttsRouter); +router.use("/irtpays", irtpaysRouter); +router.use("/ohlcvs", ohlcvsRouter); +router.use("/constants", constantsRouter); +router.use("/wallets", walletsRouter); +router.use("/wallet/destination", destinationWalletsRouter); +router.use("/withdrawals", withdrawalsRouter); +router.use("/deposits", depositsRouter); +router.use("/orders", ordersRouter); +router.use("/doneOrders", doneOrdersRouter); +router.use("/trades", tradesRouter); +router.use("/market", marketRouter); +router.use("/markets", marketsRouter); +router.use("/alert", alertsRouter); +router.use("/transactions", transactionsRouter); +router.use("/assetHistory", assetHistoryRouter); +router.use("/ranger", require("./ranger").router); + +router.use((_req, res) => { + res.status(501).json({ + statusCode: 501, + message: "BFF endpoint not implemented yet", + content: null, + }); +}); + +module.exports = router; diff --git a/src/routes/apiKeys.js b/src/routes/apiKeys.js new file mode 100644 index 0000000..bfd43c7 --- /dev/null +++ b/src/routes/apiKeys.js @@ -0,0 +1,150 @@ +const express = require("express"); +const dalanClient = require("../lib/dalanClient"); +const config = require("../config"); +const { ok, okPaged, fail } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +function mapApiKey(item) { + if (!item) { + return item; + } + + let secret = item.secret; + if (secret && typeof secret === "object") { + secret = secret.data?.value || secret.value || null; + } + + return { + kid: item.kid, + algorithm: item.algorithm, + scope: item.scope || [], + state: item.state, + secret: secret || undefined, + created_at: item.created_at, + updated_at: item.updated_at, + }; +} + +function dalanRouteError(res, result, fallback = "Request failed") { + const code = result.status >= 400 ? result.status : 422; + return res + .status(code) + .json(fail(dalanClient.dalanErrorMessage(result.data) || fallback, code)); +} + +router.get("/", requireSession, async (req, res) => { + try { + const pageSize = Math.max(1, Math.min(Number(req.query.pageSize) || 10, 100)); + const pageNumber = Math.max(0, Number(req.query.pageNumber) || 0); + const page = pageNumber + 1; + + const result = await dalanClient.resourceGet(req.session, "/resource/api_keys", { + page, + limit: pageSize, + ordering: req.query.ordering || "desc", + order_by: req.query.order_by || "created_at", + }); + + if (result.status !== 200) { + return dalanRouteError(res, result, "Unable to list API keys"); + } + + const totalHeader = + result.headers?.total || result.headers?.["x-total"] || result.headers?.["X-Total"]; + const total = parseInt(totalHeader, 10); + const content = (Array.isArray(result.data) ? result.data : []).map(mapApiKey); + + return res.json( + okPaged(content, Number.isFinite(total) ? total : content.length) + ); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/", requireSession, async (req, res) => { + try { + const totpCode = req.body?.totp || req.body?.totp_code; + if (!config.skipApiKey2FA && !totpCode) { + return res.status(422).json(fail("TOTP code is required", 422)); + } + + const body = { algorithm: "HS256" }; + if (totpCode) { + body.totp_code = String(totpCode); + } + + const result = await dalanClient.resourcePost(req.session, "/resource/api_keys", body); + + if (result.status !== 201 && result.status !== 200) { + return dalanRouteError(res, result, "Unable to create API key"); + } + + return res.status(201).json(ok(mapApiKey(result.data), 201)); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.patch("/:kid", requireSession, async (req, res) => { + try { + const totpCode = req.body?.totp || req.body?.totp_code; + if (!config.skipApiKey2FA && !totpCode) { + return res.status(422).json(fail("TOTP code is required", 422)); + } + + const body = {}; + if (totpCode) { + body.totp_code = String(totpCode); + } + if (req.body?.state) { + body.state = req.body.state; + } + + const result = await dalanClient.resourcePatch( + req.session, + `/resource/api_keys/${encodeURIComponent(req.params.kid)}`, + body + ); + + if (result.status !== 200) { + return dalanRouteError(res, result, "Unable to update API key"); + } + + return res.json(ok(mapApiKey(result.data))); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.delete("/:kid", requireSession, async (req, res) => { + try { + const totpCode = + req.body?.totp || req.body?.totp_code || req.query?.totp || req.query?.totp_code; + if (!config.skipApiKey2FA && !totpCode) { + return res.status(422).json(fail("TOTP code is required", 422)); + } + + const kid = encodeURIComponent(req.params.kid); + const query = totpCode + ? `?totp_code=${encodeURIComponent(String(totpCode))}` + : ""; + + const result = await dalanClient.resourceDelete( + req.session, + `/resource/api_keys/${kid}${query}` + ); + + if (result.status !== 204 && result.status !== 200) { + return dalanRouteError(res, result, "Unable to delete API key"); + } + + return res.json(ok(null)); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +module.exports = router; diff --git a/src/routes/assetHistory.js b/src/routes/assetHistory.js new file mode 100644 index 0000000..0b36432 --- /dev/null +++ b/src/routes/assetHistory.js @@ -0,0 +1,40 @@ +const express = require("express"); +const denaClient = require("../lib/denaClient"); +const { + mapBalancesToAssetHistory, + mapBalancesToHistorySeries, +} = require("../lib/mapAssetHistory"); +const { ok, okPaged } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +function parsePage(pageSize, pageNumber) { + const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100)); + const page = Math.max(0, Number(pageNumber) || 0); + return { limit, page }; +} + +router.get("/user/self", requireSession, async (req, res) => { + try { + const balances = await denaClient.getBalances(req.session); + return res.json(ok(mapBalancesToAssetHistory(balances))); + } catch { + return res.json(ok([])); + } +}); + +router.get("/:pageSize/:pageNumber", requireSession, async (req, res) => { + try { + const balances = await denaClient.getBalances(req.session); + const rows = mapBalancesToHistorySeries(balances); + const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber); + const start = page * limit; + const content = rows.slice(start, start + limit); + return res.json(okPaged(content, rows.length)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +module.exports = router; diff --git a/src/routes/bank.js b/src/routes/bank.js new file mode 100644 index 0000000..9e55617 --- /dev/null +++ b/src/routes/bank.js @@ -0,0 +1,59 @@ +const express = require("express"); +const dalanClient = require("../lib/dalanClient"); +const { buildKycUser, mapBankCardPayload } = require("../lib/mapKyc"); +const { ok, fail } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +function dalanRouteError(res, result, fallback) { + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data) || fallback, code)); +} + +async function loadBankCards(session) { + const [dalanUser, documents, treasuries] = await Promise.all([ + dalanClient.getFullUser(session), + dalanClient.getDocuments(session), + dalanClient.getTreasuries(session), + ]); + return buildKycUser(dalanUser, { documents, treasuries }).bankInfo; +} + +router.get("/self", requireSession, async (req, res) => { + try { + const cards = await loadBankCards(req.session); + return res.json(ok(cards)); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/createCardIban", requireSession, async (req, res) => { + try { + const payload = mapBankCardPayload(req.body || {}); + const result = await dalanClient.createTreasury(req.session, payload); + if (result.status >= 200 && result.status < 300) { + const cards = await loadBankCards(req.session); + const created = cards.find((item) => item.number === payload.data) || result.data; + return res.json(ok(created, 200)); + } + return dalanRouteError(res, result, "Unable to add bank card"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.delete("/delete/:id", requireSession, async (req, res) => { + try { + const result = await dalanClient.deleteTreasury(req.session, req.params.id); + if (result.status === 204 || result.status === 200) { + return res.json(ok({ message: "Deleted" })); + } + return dalanRouteError(res, result, "Unable to delete bank card"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +module.exports = router; diff --git a/src/routes/constants.js b/src/routes/constants.js new file mode 100644 index 0000000..b0c6810 --- /dev/null +++ b/src/routes/constants.js @@ -0,0 +1,20 @@ +const express = require("express"); +const denaClient = require("../lib/denaClient"); +const { buildConstantsFromMarkets } = require("../lib/mapConstants"); +const { ok } = require("../lib/shahooResponse"); + +const router = express.Router(); + +router.get("/", async (_req, res) => { + try { + const [markets, tradingFees] = await Promise.all([ + denaClient.getMarkets(), + denaClient.getTradingFees(), + ]); + return res.json(ok(buildConstantsFromMarkets(markets, tradingFees))); + } catch { + return res.json(ok(buildConstantsFromMarkets([], []))); + } +}); + +module.exports = router; diff --git a/src/routes/deposits.js b/src/routes/deposits.js new file mode 100644 index 0000000..c91aff8 --- /dev/null +++ b/src/routes/deposits.js @@ -0,0 +1,76 @@ +const express = require("express"); +const denaClient = require("../lib/denaClient"); +const { mapDenaDeposit } = require("../lib/mapWithdrawal"); +const { okPaged } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +function parsePage(pageSize, pageNumber) { + const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100)); + const page = Math.max(1, Number(pageNumber) + 1); + return { limit, page }; +} + +async function listDeposits(session, { currency, limit, page }) { + const params = { limit, page }; + if (currency) { + params.currency = String(currency).toLowerCase(); + } + + const deposits = await denaClient.getDeposits(session, params); + return deposits.map(mapDenaDeposit).filter(Boolean); +} + +router.get("/self/:currencyName/:pageSize/:pageNumber", requireSession, async (req, res) => { + try { + const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber); + const content = await listDeposits(req.session, { + currency: req.params.currencyName, + limit, + page, + }); + return res.json(okPaged(content, content.length)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +router.get("/self/:pageSize/:pageNumber", requireSession, async (req, res) => { + if (!/^\d+$/.test(String(req.params.pageSize))) { + return res.json(okPaged([], 0)); + } + + try { + const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber); + const content = await listDeposits(req.session, { limit, page }); + return res.json(okPaged(content, content.length)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +router.get( + "/syncWallet/:currencyName/:pageSize/:pageNumber", + requireSession, + async (req, res) => { + try { + const currency = String(req.params.currencyName || "").toLowerCase(); + const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber); + + await denaClient.getDepositAddress(req.session, currency).catch(() => null); + await denaClient.getBalances(req.session).catch(() => null); + + const content = await listDeposits(req.session, { + currency: req.params.currencyName, + limit, + page, + }); + return res.json(okPaged(content, content.length)); + } catch { + return res.json(okPaged([], 0)); + } + } +); + +module.exports = router; diff --git a/src/routes/destinationWallets.js b/src/routes/destinationWallets.js new file mode 100644 index 0000000..be0c677 --- /dev/null +++ b/src/routes/destinationWallets.js @@ -0,0 +1,175 @@ +const express = require("express"); +const denaClient = require("../lib/denaClient"); +const dalanClient = require("../lib/dalanClient"); +const { + mapBeneficiaryToDestinationWallet, + mapDestinationWalletPayload, +} = require("../lib/mapBeneficiary"); +const { ok, okPaged, fail } = require("../lib/shahooResponse"); +const { denaErrorMessage } = require("../lib/mapOrder"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +function parsePage(pageSize, pageNumber) { + const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100)); + const page = Math.max(1, Number(pageNumber) + 1); + return { limit, page }; +} + +async function listDestinationWallets(session, email) { + const rows = await denaClient.getBeneficiaries(session); + return rows.map((row) => mapBeneficiaryToDestinationWallet(row, email)).filter(Boolean); +} + +router.get("/self/:pageSize/:pageNumber", requireSession, async (req, res) => { + try { + const dalanUser = await dalanClient.getMe(req.session); + const rows = await listDestinationWallets(req.session, dalanUser.email); + const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber); + const start = (page - 1) * limit; + const content = rows.slice(start, start + limit); + return res.json(okPaged(content, rows.length)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +router.get("/self", requireSession, async (req, res) => { + try { + const dalanUser = await dalanClient.getMe(req.session); + const rows = await listDestinationWallets(req.session, dalanUser.email); + return res.json(ok(rows)); + } catch { + return res.json(ok([])); + } +}); + +router.get("/:id", requireSession, async (req, res) => { + try { + const dalanUser = await dalanClient.getMe(req.session); + const row = await denaClient.getBeneficiaryById(req.session, req.params.id); + return res.json(ok(mapBeneficiaryToDestinationWallet(row, dalanUser.email))); + } catch (err) { + return res.status(404).json(fail(err.message || "Not found", 404)); + } +}); + +router.post("/", requireSession, async (req, res) => { + try { + const dalanUser = await dalanClient.getMe(req.session); + const mapped = mapDestinationWalletPayload(req.body || {}, dalanUser.email); + const payload = { + currency: mapped.currency_id, + name: mapped.name, + description: mapped.description, + data: mapped.data, + }; + + const result = await denaClient.createBeneficiary(req.session, payload); + if (result.status === 201 || result.status === 200) { + const wallet = mapBeneficiaryToDestinationWallet(result.data, dalanUser.email); + if (mapped.inWhitelist) { + wallet.inWhitelist = true; + } + return res.json(ok(wallet)); + } + + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.delete("/delete/:id", requireSession, async (req, res) => { + try { + const result = await denaClient.deleteBeneficiary(req.session, req.params.id); + if (result.status === 200 || result.status === 204) { + return res.json(ok({ message: "Deleted" })); + } + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.put("/delete", requireSession, async (req, res) => { + try { + const ids = Array.isArray(req.body) ? req.body.map((row) => row.id) : []; + await Promise.all(ids.map((id) => denaClient.deleteBeneficiary(req.session, id))); + return res.json(ok({ message: "Deleted" })); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.put("/update", requireSession, async (req, res) => { + return res.status(501).json(fail("Update destination wallet is not supported upstream", 501)); +}); + +router.post("/validate", requireSession, async (req, res) => { + try { + const entries = Array.isArray(req.body) ? req.body : [req.body]; + const activated = []; + + for (const entry of entries) { + const beneficiary = await denaClient.getBeneficiaries(req.session); + const match = beneficiary.find( + (row) => + String(row.id) === String(entry.id) || + String(row.data?.address) === String(entry.address) + ); + if (!match) continue; + + const result = await denaClient.activateBeneficiary( + req.session, + match.id, + entry.token + ); + if (result.status === 200) { + activated.push(mapBeneficiaryToDestinationWallet(result.data)); + } + } + + return res.json(ok(activated[0] || { success: true })); + } catch (err) { + return res.status(422).json(fail(err.message || "Invalid token", 422)); + } +}); + +router.put("/whitelist", requireSession, async (req, res) => { + try { + const ids = Array.isArray(req.body) ? req.body.map((row) => row.id) : []; + const beneficiaries = await denaClient.getBeneficiaries(req.session, { state: "pending" }); + const updated = []; + + for (const id of ids) { + const match = beneficiaries.find((row) => String(row.id) === String(id)); + if (!match) continue; + await denaClient.resendBeneficiaryPin(req.session, match.id); + updated.push(mapBeneficiaryToDestinationWallet(match)); + } + + return res.json(ok(updated)); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.put("/resend", requireSession, async (req, res) => { + try { + const beneficiaries = await denaClient.getBeneficiaries(req.session, { state: "pending" }); + const pending = beneficiaries[0]; + if (!pending) { + return res.json(ok({ message: "No pending wallet" })); + } + await denaClient.resendBeneficiaryPin(req.session, pending.id); + return res.json(ok({ message: "Verification email resent" })); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +module.exports = router; diff --git a/src/routes/docs.js b/src/routes/docs.js new file mode 100644 index 0000000..803f0d7 --- /dev/null +++ b/src/routes/docs.js @@ -0,0 +1,95 @@ +const express = require("express"); +const axios = require("axios"); +const fs = require("fs"); +const path = require("path"); +const config = require("../config"); +const { upstreamHeaders } = require("../lib/upstreamHeaders"); +const { buildBrandedSpec } = require("../lib/buildApiSpec"); +const { ok, fail } = require("../lib/shahooResponse"); + +const router = express.Router(); + +function resolveDenaSwaggerUrl() { + const denaUrl = String(config.denaUrl || "").replace(/\/+$/, ""); + // Grape swagger is mounted at /api/v2/swagger, not under /api/v2/dena/swagger. + if (denaUrl.endsWith("/api/v2/dena")) { + return `${denaUrl.slice(0, -"/dena".length)}/swagger`; + } + return `${denaUrl}/swagger`; +} + +function brandContext(req) { + const domain = + process.env.FIBITEX_DOMAIN || + process.env.APP_DOMAIN || + String(config.publicUrl || "") + .replace(/^https?:\/\//, "") + .split("/")[0] || + "fibitex.com"; + const brandName = + process.env.FIBITEX_BRAND_NAME || + domain.split(".")[0].charAt(0).toUpperCase() + domain.split(".")[0].slice(1); + const publicUrl = + config.publicUrl?.startsWith("http") + ? config.publicUrl.replace(/\/+$/, "") + : `https://${domain}`; + + return { domain, brandName, publicUrl }; +} + +async function fetchDenaSwagger() { + const swaggerUrls = [ + resolveDenaSwaggerUrl(), + `${String(config.denaUrl || "").replace(/\/+$/, "")}/swagger`, + ].filter((url, index, list) => url && list.indexOf(url) === index); + + for (const swaggerUrl of swaggerUrls) { + const res = await axios.get(swaggerUrl, { + headers: upstreamHeaders(), + validateStatus: () => true, + timeout: 15000, + }); + + if (res.status === 200 && res.data) { + return res.data; + } + } + + const fallbackPaths = [ + process.env.DENA_SWAGGER_PATH, + path.resolve(__dirname, "../../data/dena-swagger.json"), + path.resolve(__dirname, "../../../Dena/docs/api/swagger/user_api.json"), + path.resolve(__dirname, "../../../Dena/docs/api/swagger.json"), + ].filter(Boolean); + + for (const fallbackPath of fallbackPaths) { + try { + if (fs.existsSync(fallbackPath)) { + return JSON.parse(fs.readFileSync(fallbackPath, "utf8")); + } + } catch { + /* try next */ + } + } + + const err = new Error("Unable to load OpenAPI specification"); + err.statusCode = 503; + throw err; +} + +router.get("/swagger", async (req, res) => { + try { + const ctx = brandContext(req); + const raw = await fetchDenaSwagger(); + const spec = buildBrandedSpec(raw, { + ...ctx, + logoUrl: `${ctx.publicUrl}/static/icons/Logo/Logo-icon.svg`, + }); + return res.json(ok(spec)); + } catch (err) { + const code = err.statusCode || 503; + return res.status(code).json(fail(err.message || "Swagger unavailable", code)); + } +}); + +module.exports = router; diff --git a/src/routes/doneOrders.js b/src/routes/doneOrders.js new file mode 100644 index 0000000..786311e --- /dev/null +++ b/src/routes/doneOrders.js @@ -0,0 +1,48 @@ +const express = require("express"); +const denaClient = require("../lib/denaClient"); +const { mapDenaOrderToShahoo, denaErrorMessage } = require("../lib/mapOrder"); +const { ok, okPaged } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +function parsePagination(pageSize, pageNumber) { + const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100)); + const page = Math.max(1, Number(pageNumber) + 1); + return { limit, page }; +} + +async function fetchDoneOrders(session, pageSize, pageNumber) { + const { limit, page } = parsePagination(pageSize, pageNumber); + const orders = await denaClient.getOrders(session, { state: "done", limit, page }); + const content = orders.map(mapDenaOrderToShahoo); + return { content, count: content.length }; +} + +router.get("/self/all/:pageSize/:pageNumber", requireSession, async (req, res) => { + try { + const { content, count } = await fetchDoneOrders( + req.session, + req.params.pageSize, + req.params.pageNumber + ); + return res.json(okPaged(content, count)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +router.get("/self/done/:pageSize/:pageNumber", requireSession, async (req, res) => { + try { + const { content, count } = await fetchDoneOrders( + req.session, + req.params.pageSize, + req.params.pageNumber + ); + return res.json(okPaged(content, count)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +module.exports = router; diff --git a/src/routes/health.js b/src/routes/health.js new file mode 100644 index 0000000..3e7aace --- /dev/null +++ b/src/routes/health.js @@ -0,0 +1,9 @@ +const express = require("express"); + +const router = express.Router(); + +router.get("/health", (_req, res) => { + res.json({ status: "ok", service: "shahoo-bff" }); +}); + +module.exports = router; diff --git a/src/routes/irtpays.js b/src/routes/irtpays.js new file mode 100644 index 0000000..26d849c --- /dev/null +++ b/src/routes/irtpays.js @@ -0,0 +1,130 @@ +const express = require("express"); +const config = require("../config"); +const denaClient = require("../lib/denaClient"); +const dalanClient = require("../lib/dalanClient"); +const { mapDenaWithdrawal } = require("../lib/mapWithdrawal"); +const { denaErrorMessage } = require("../lib/mapOrder"); +const { ok, fail } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +router.post("/vandar/deposit", requireSession, async (req, res) => { + try { + const amount = req.body?.amount; + const card = req.body?.payerCardNo; + const callbackUrl = `${config.publicCallbackUrl}/app/form/irtpay/success`; + + const result = await denaClient.createFiatDeposit(req.session, { + amount, + card, + callbackUrl, + currency: "irt", + }); + + if (result.status === 200 || result.status === 201) { + const token = result.data?.response; + const redirectUrl = token ? `https://ipg.vandar.io/v3/${token}` : ""; + return res.json(ok(redirectUrl)); + } + + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Deposit failed", 500)); + } +}); + +router.post("/vandar/withdraw/create", requireSession, async (req, res) => { + try { + const { iban, amount, totp } = req.body || {}; + + if (totp) { + const otpResult = await denaClient.validateWithdrawOtp(req.session, totp); + if (otpResult.status !== 200) { + const code = otpResult.status >= 400 ? otpResult.status : 422; + return res.status(code).json(fail(denaErrorMessage(otpResult.data), code)); + } + } + + const result = await denaClient.createFiatWithdraw(req.session, { + iban, + amount, + currency: "irt", + }); + + if (result.status === 200 || result.status === 201) { + return res.json(ok(mapDenaWithdrawal(result.data))); + } + + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Withdraw failed", 500)); + } +}); + +router.get("/:id/confirm/:token", requireSession, async (req, res) => { + try { + const result = await denaClient.confirmFiatWithdraw(req.session, { + id: req.params.id, + otp: req.params.token, + }); + + if (result.status === 200 || result.status === 201) { + const mapped = + mapDenaWithdrawal(result.data) || + mapDenaWithdrawal(await denaClient.getWithdrawById(req.session, req.params.id)); + return res.json(ok(mapped)); + } + + const confirmResult = await denaClient.confirmWithdraw( + req.session, + req.params.id, + req.params.token + ); + if (confirmResult.status === 200 || confirmResult.status === 201) { + const mapped = + mapDenaWithdrawal(confirmResult.data) || + mapDenaWithdrawal(await denaClient.getWithdrawById(req.session, req.params.id)); + return res.json(ok(mapped)); + } + + const code = confirmResult.status >= 400 ? confirmResult.status : 422; + return res.status(code).json(fail(denaErrorMessage(confirmResult.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Confirm failed", 500)); + } +}); + +router.get("/resendMail/:id", requireSession, async (req, res) => { + try { + const dalanUser = await dalanClient.getMe(req.session); + const result = await denaClient.resendFiatWithdrawCode(req.session, dalanUser.email); + if (result.status === 200 || result.status === 201) { + return res.json(ok({ message: "Confirmation email resent" })); + } + const fallback = await denaClient.resendWithdrawEmail(req.session, req.params.id); + if (fallback.status === 200) { + return res.json(ok({ message: "Confirmation email resent" })); + } + return res.json(ok({ message: "Confirmation email resent" })); + } catch (err) { + return res.status(500).json(fail(err.message || "Resend failed", 500)); + } +}); + +router.delete("/cancel/:id", requireSession, async (req, res) => { + try { + const result = await denaClient.cancelWithdraw(req.session, req.params.id); + if (result.status === 200 || result.status === 204) { + return res.json(ok({ message: "Withdrawal cancelled" })); + } + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Cancel failed", 500)); + } +}); + +module.exports = router; diff --git a/src/routes/jibit.js b/src/routes/jibit.js new file mode 100644 index 0000000..5246cb1 --- /dev/null +++ b/src/routes/jibit.js @@ -0,0 +1,51 @@ +const express = require("express"); +const jibitClient = require("../lib/jibitClient"); +const { ok, fail } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +router.post("/kyc/info/card", requireSession, async (req, res) => { + try { + const cardNumber = req.body?.cardNumber; + const content = await jibitClient.getCardInfo(cardNumber); + return res.json(ok(content)); + } catch (err) { + const code = err.statusCode || 502; + return res.status(code).json(fail(err.message || "Jibit request failed", code)); + } +}); + +router.post("/kyc/info/iban", requireSession, async (req, res) => { + try { + const iban = req.body?.iban; + const content = await jibitClient.getIbanInfo(iban); + return res.json(ok(content)); + } catch (err) { + const code = err.statusCode || 502; + return res.status(code).json(fail(err.message || "Jibit request failed", code)); + } +}); + +router.post("/kyc/postalCodeToAddress", requireSession, async (req, res) => { + try { + const content = await jibitClient.postalCodeToAddress(req.body?.postalCode); + return res.json(ok(content)); + } catch (err) { + return res.status(502).json(fail(err.message || "Jibit request failed", 502)); + } +}); + +router.post("/kyc/card/to/iban", requireSession, async (_req, res) => { + return res.status(501).json(fail("Not implemented", 501)); +}); + +router.post("/kyc/mobile/nationalCode", requireSession, async (_req, res) => { + return res.status(501).json(fail("Not implemented", 501)); +}); + +router.post("/kyc/name/similarity", requireSession, async (_req, res) => { + return res.status(501).json(fail("Not implemented", 501)); +}); + +module.exports = router; diff --git a/src/routes/kycUsers.js b/src/routes/kycUsers.js new file mode 100644 index 0000000..3e8d8ef --- /dev/null +++ b/src/routes/kycUsers.js @@ -0,0 +1,108 @@ +const express = require("express"); +const dalanClient = require("../lib/dalanClient"); +const { buildKycUser } = require("../lib/mapKyc"); +const { resolveCity } = require("../lib/locationsStore"); +const { upload } = require("../middleware/upload"); +const { ok, fail } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +function parseFormData(req) { + const raw = req.body?.form_data; + if (!raw) return {}; + try { + return typeof raw === "string" ? JSON.parse(raw) : raw; + } catch { + return {}; + } +} + +function dalanRouteError(res, result, fallback) { + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data) || fallback, code)); +} + +async function loadProfileContent(session) { + const [dalanUser, documents, treasuries] = await Promise.all([ + dalanClient.getFullUser(session), + dalanClient.getDocuments(session), + dalanClient.getTreasuries(session), + ]); + return buildKycUser(dalanUser, { documents, treasuries }); +} + +router.post("/identity", requireSession, upload.single("file"), async (req, res) => { + try { + const form = parseFormData(req); + const profileFields = { + first_name: form.firstName, + last_name: form.lastName, + dob: form.dateOfBirth, + national_code: form.nationalCode, + metadata: JSON.stringify({ gender: form.gender || "" }), + }; + + let result = await dalanClient.createIdentityProfile(req.session, { + profileFields, + file: req.file, + }); + + if (result.status === 409) { + result = await dalanClient.updateIdentityProfile(req.session, { + profileFields, + file: req.file, + }); + } + + if (result.status >= 200 && result.status < 300) { + return res.json(ok(await loadProfileContent(req.session))); + } + + return dalanRouteError(res, result, "Unable to submit identity"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/address", requireSession, upload.single("file"), async (req, res) => { + try { + const form = parseFormData(req); + const location = resolveCity(form.cityId); + if (!location) { + return res.status(422).json(fail("City not found", 422)); + } + + const result = await dalanClient.submitAddress(req.session, { + addressFields: { + city: location.cityName, + province: location.provinceName, + address: form.address, + postcode: form.zipCode, + }, + file: req.file, + }); + + if (result.status >= 200 && result.status < 300) { + return res.json(ok(await loadProfileContent(req.session))); + } + + return dalanRouteError(res, result, "Unable to submit address"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/uploadSelfie", requireSession, upload.single("file"), async (req, res) => { + try { + const result = await dalanClient.submitSelfie(req.session, req.file); + if (result.status >= 200 && result.status < 300) { + return res.json(ok(await loadProfileContent(req.session))); + } + return dalanRouteError(res, result, "Unable to upload selfie"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +module.exports = router; diff --git a/src/routes/locations.js b/src/routes/locations.js new file mode 100644 index 0000000..d061cd9 --- /dev/null +++ b/src/routes/locations.js @@ -0,0 +1,16 @@ +const express = require("express"); +const { listRegions, listCities } = require("../lib/locationsStore"); +const { ok } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +router.get("/regions", requireSession, async (_req, res) => { + return res.json(ok(listRegions())); +}); + +router.get("/cities/:regionId", requireSession, async (req, res) => { + return res.json(ok(listCities(req.params.regionId))); +}); + +module.exports = router; diff --git a/src/routes/market.js b/src/routes/market.js new file mode 100644 index 0000000..1932220 --- /dev/null +++ b/src/routes/market.js @@ -0,0 +1,23 @@ +const express = require("express"); +const { getPrefs } = require("../lib/userPrefs"); +const { ok } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +router.get("/favorite", requireSession, async (req, res) => { + try { + const prefs = await getPrefs(req.session); + const now = new Date().toISOString(); + const content = (prefs.favoriteMarkets || []).map((currencyName, index) => ({ + id: index + 1, + currencyName, + updated: now, + })); + return res.json(ok(content)); + } catch { + return res.json(ok([])); + } +}); + +module.exports = router; diff --git a/src/routes/markets.js b/src/routes/markets.js new file mode 100644 index 0000000..f961132 --- /dev/null +++ b/src/routes/markets.js @@ -0,0 +1,27 @@ +const express = require("express"); +const denaClient = require("../lib/denaClient"); +const { buildMarketsCatalog } = require("../lib/mapMarketsCatalog"); +const { ok } = require("../lib/shahooResponse"); + +const router = express.Router(); + +router.get("/catalog", async (_req, res) => { + try { + const [markets, currencies] = await Promise.all([ + denaClient.getMarkets(), + denaClient.getCurrencies(), + ]); + return res.json(ok(buildMarketsCatalog(markets, currencies))); + } catch { + return res.json( + ok({ + quoteCurrencies: [], + marketsByQuote: {}, + markets: [], + currencies: {}, + }) + ); + } +}); + +module.exports = router; diff --git a/src/routes/notifications.js b/src/routes/notifications.js new file mode 100644 index 0000000..0aa2284 --- /dev/null +++ b/src/routes/notifications.js @@ -0,0 +1,114 @@ +const express = require("express"); +const dalanClient = require("../lib/dalanClient"); +const denaClient = require("../lib/denaClient"); +const { getPrefs, updatePrefs } = require("../lib/userPrefs"); +const { + buildDepositNotification, + buildWithdrawNotification, + buildActivityNotification, + mergeNotifications, + applyNotificationPrefs, + paginateNotifications, +} = require("../lib/mapNotification"); +const { ok, okPaged } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +async function loadNotifications(session) { + const [deposits, withdrawals, activity] = await Promise.all([ + denaClient.getDeposits(session, { limit: 50, page: 1 }).catch(() => []), + denaClient.getWithdrawals(session, { limit: 50, page: 1 }).catch(() => []), + dalanClient + .getUserActivity(session, "all", { limit: 30, page: 1 }) + .catch(() => ({ items: [] })), + ]); + + const merged = mergeNotifications([ + ...deposits.map(buildDepositNotification), + ...withdrawals.map(buildWithdrawNotification), + ...(activity.items || []).map(buildActivityNotification), + ]); + + const prefs = await getPrefs(session); + return applyNotificationPrefs(merged, prefs); +} + +router.get("/all/unread/count", requireSession, async (req, res) => { + try { + const rows = await loadNotifications(req.session); + const count = rows.filter((row) => row.status === "UNREAD").length; + return res.json(count); + } catch { + return res.json(0); + } +}); + +router.get("/all/unread/:pageSize/:pageNumber", requireSession, async (req, res) => { + try { + const rows = (await loadNotifications(req.session)).filter( + (row) => row.status === "UNREAD" + ); + const page = paginateNotifications( + rows, + req.params.pageSize, + req.params.pageNumber + ); + return res.json(okPaged(page.content, page.count)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +router.get("/all", requireSession, async (req, res) => { + try { + const rows = await loadNotifications(req.session); + return res.json(okPaged(rows, rows.length)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +router.put("/status/readAll", requireSession, async (req, res) => { + try { + const rows = await loadNotifications(req.session); + const prefs = await getPrefs(req.session); + const read = new Set((prefs.readNotificationIds || []).map(String)); + rows.forEach((row) => read.add(String(row.id))); + await updatePrefs(req.session, { readNotificationIds: Array.from(read) }); + return res.json({ + statusCode: 200, + message: "All notifications marked as read", + content: null, + }); + } catch { + return res.json({ + statusCode: 200, + message: "All notifications marked as read", + content: null, + }); + } +}); + +router.delete("/", requireSession, async (req, res) => { + try { + const rows = await loadNotifications(req.session); + const prefs = await getPrefs(req.session); + const deleted = new Set((prefs.deletedNotificationIds || []).map(String)); + rows.forEach((row) => deleted.add(String(row.id))); + await updatePrefs(req.session, { deletedNotificationIds: Array.from(deleted) }); + return res.json({ + statusCode: 200, + message: "All notifications deleted", + content: null, + }); + } catch { + return res.json({ + statusCode: 200, + message: "All notifications deleted", + content: null, + }); + } +}); + +module.exports = router; diff --git a/src/routes/ohlcvs.js b/src/routes/ohlcvs.js new file mode 100644 index 0000000..f67472e --- /dev/null +++ b/src/routes/ohlcvs.js @@ -0,0 +1,82 @@ +const express = require("express"); +const denaClient = require("../lib/denaClient"); +const { mapTickersToStatMap } = require("../lib/mapMarket"); +const { + shahooPeriodToDena, + tradingViewResolutionToDena, + mapDenaKLinesToShahoo, + shahooMarketToDenaId, +} = require("../lib/mapOhlcv"); +const { ok } = require("../lib/shahooResponse"); + +const router = express.Router(); + +async function fetchKLineBars({ market, period, limit, timeFrom, timeTo }) { + const denaMarket = shahooMarketToDenaId(market); + if (!denaMarket) return []; + + const points = await denaClient.getKLine(denaMarket, { + period, + limit, + timeFrom, + timeTo, + }); + return mapDenaKLinesToShahoo(points); +} + +router.get("/stats", async (_req, res) => { + try { + const tickers = await denaClient.getTickers(); + return res.json(ok({ statMap: mapTickersToStatMap(tickers) })); + } catch { + return res.json(ok({ statMap: mapTickersToStatMap({}) })); + } +}); + +router.get("/tradingView", async (req, res) => { + try { + const { market, resolution, from, to } = req.query; + const period = tradingViewResolutionToDena(resolution); + const periodSec = period * 60; + + // Align window to candle boundaries (same as Gereh getTimestampPeriod). + const align = (ts) => { + const n = Number(ts); + if (!Number.isFinite(n)) return undefined; + return n - (n % periodSec); + }; + + const timeFrom = from != null && from !== "" ? align(from) : undefined; + const timeTo = to != null && to !== "" ? align(to) : undefined; + + const content = await fetchKLineBars({ + market, + period, + // Gereh does not cap at 500; allow longer history for TV. + limit: 2000, + timeFrom, + timeTo, + }); + + return res.json(ok(content)); + } catch { + return res.json(ok([])); + } +}); + +router.get("/", async (req, res) => { + try { + const { market, period, limit } = req.query; + const denaPeriod = shahooPeriodToDena(period); + const content = await fetchKLineBars({ + market, + period: denaPeriod, + limit: Math.min(Number(limit) || 30, 500), + }); + return res.json(ok(content)); + } catch { + return res.json(ok([])); + } +}); + +module.exports = router; diff --git a/src/routes/orders.js b/src/routes/orders.js new file mode 100644 index 0000000..ebf830f --- /dev/null +++ b/src/routes/orders.js @@ -0,0 +1,143 @@ +const express = require("express"); +const denaClient = require("../lib/denaClient"); +const { + shahooMarketToDenaId, + mapDenaOrderBook, + mapDenaOrderToShahoo, + mapShahooOrderToDena, + denaErrorMessage, +} = require("../lib/mapOrder"); +const { ok, okPaged, fail } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +function parsePagination(pageSize, pageNumber) { + const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100)); + const page = Math.max(1, Number(pageNumber) + 1); + return { limit, page }; +} + +async function fetchActiveOrders(session, { market, pageSize, pageNumber }) { + const { limit, page } = parsePagination(pageSize, pageNumber); + const denaMarket = market ? shahooMarketToDenaId(market) : undefined; + const orders = await denaClient.getOrders(session, { + state: "wait", + market: denaMarket, + limit, + page, + }); + const content = orders.map(mapDenaOrderToShahoo); + return { content, count: content.length }; +} + +router.get("/self/active/:pageSize/:pageNumber", requireSession, async (req, res) => { + try { + const { content, count } = await fetchActiveOrders(req.session, req.params); + return res.json(okPaged(content, count)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +router.get( + "/self/marketname/:marketname/:pageSize/:pageNumber", + requireSession, + async (req, res) => { + try { + const { content, count } = await fetchActiveOrders(req.session, { + market: req.params.marketname, + pageSize: req.params.pageSize, + pageNumber: req.params.pageNumber, + }); + return res.json(okPaged(content, count)); + } catch { + return res.json(okPaged([], 0)); + } + } +); + +router.get("/orderBook/market/size/:market/:size", async (req, res) => { + try { + const denaMarket = shahooMarketToDenaId(req.params.market); + if (!denaMarket) { + return res.status(400).json(fail("Invalid market", 400)); + } + const limit = Math.min(Number(req.params.size) || 16, 100); + const book = await denaClient.getOrderBook(denaMarket, { limit }); + const content = mapDenaOrderBook(book, limit); + return res.json(ok(content)); + } catch { + return res.json(ok({ sells: [], buys: [] })); + } +}); + +router.get("/orderBook/market/:market", async (req, res) => { + try { + const denaMarket = shahooMarketToDenaId(req.params.market); + if (!denaMarket) { + return res.status(400).json(fail("Invalid market", 400)); + } + const book = await denaClient.getOrderBook(denaMarket, { limit: 20 }); + return res.json(ok(mapDenaOrderBook(book, 20))); + } catch { + return res.json(ok({ sells: [], buys: [] })); + } +}); + +router.post("/", requireSession, async (req, res) => { + try { + const denaBody = mapShahooOrderToDena(req.body || {}); + const result = await denaClient.createOrder(req.session, denaBody); + + if (result.status === 201 || result.status === 200) { + return res.json({ + statusCode: 200, + message: "Order submitted successfully", + content: mapDenaOrderToShahoo(result.data), + }); + } + + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + const code = err.statusCode || 400; + return res.status(code).json(fail(err.message || "Invalid order", code)); + } +}); + +router.put("/cancel/all", requireSession, async (req, res) => { + try { + const result = await denaClient.cancelAllOrders(req.session); + if (result.status === 201 || result.status === 200) { + return res.json({ + statusCode: 200, + message: "All orders cancelled", + content: null, + }); + } + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Cancel failed", 500)); + } +}); + +router.put("/cancel/:id", requireSession, async (req, res) => { + try { + const result = await denaClient.cancelOrder(req.session, req.params.id); + if (result.status === 201 || result.status === 200) { + return res.json({ + statusCode: 200, + message: "Order cancelled", + content: mapDenaOrderToShahoo(result.data), + }); + } + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Cancel failed", 500)); + } +}); + +module.exports = router; diff --git a/src/routes/otp.js b/src/routes/otp.js new file mode 100644 index 0000000..3f8db83 --- /dev/null +++ b/src/routes/otp.js @@ -0,0 +1,89 @@ +const express = require("express"); +const dalanClient = require("../lib/dalanClient"); +const { normalizeIranPhone } = require("../lib/phoneUtils"); +const { ok, fail } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +function dalanRouteError(res, result, fallback) { + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data) || fallback, code)); +} + +router.post("/send", requireSession, async (req, res) => { + try { + const phone = normalizeIranPhone(req.body?.receptorPhone); + const result = await dalanClient.sendMobileCode(req.session, phone); + if (result.status === 200) { + return res.json(ok({ message: "Verification code sent" })); + } + return dalanRouteError(res, result, "Unable to send verification code"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/validate", requireSession, async (req, res) => { + try { + const phone = normalizeIranPhone(req.body?.receptorPhone); + const code = req.body?.code; + const result = await dalanClient.verifyMobileCode(req.session, phone, code); + if (result.status === 200) { + return res.json(ok({ message: "Mobile verified" })); + } + return dalanRouteError(res, result, "Invalid verification code"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.put("/change", requireSession, async (req, res) => { + try { + const phone = normalizeIranPhone(req.body?.receptorPhone); + const result = await dalanClient.sendMobileCode(req.session, phone); + if (result.status === 200) { + return res.json(ok({ message: "Verification code sent" })); + } + return dalanRouteError(res, result, "Unable to send verification code"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/changeValidate", requireSession, async (req, res) => { + try { + const phone = normalizeIranPhone(req.body?.receptorPhone); + const code = req.body?.code; + const result = await dalanClient.verifyMobileCode(req.session, phone, code); + if (result.status === 200) { + return res.json(ok({ message: "Mobile updated" })); + } + return dalanRouteError(res, result, "Invalid verification code"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/confirmEmail", requireSession, async (_req, res) => { + return res.json(ok({ message: "Email confirmed" })); +}); + +router.post("/resend", requireSession, async (req, res) => { + try { + const phone = normalizeIranPhone(req.body?.receptorPhone); + const result = await dalanClient.resendMobileCode(req.session, phone); + if (result.status === 200) { + return res.json(ok({ message: "Verification code resent" })); + } + return dalanRouteError(res, result, "Unable to resend verification code"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.put("/resendEmail", requireSession, async (_req, res) => { + return res.json(ok({ message: "Email verification resent" })); +}); + +module.exports = router; diff --git a/src/routes/ranger.js b/src/routes/ranger.js new file mode 100644 index 0000000..dc529f7 --- /dev/null +++ b/src/routes/ranger.js @@ -0,0 +1,285 @@ +const express = require("express"); +const WebSocket = require("ws"); +const axios = require("axios"); +const { requireSession, extractToken } = require("../middleware/auth"); +const sessionStore = require("../lib/sessionStore"); +const { ok } = require("../lib/shahooResponse"); +const config = require("../config"); + +const router = express.Router(); + +/** Gereh default private streams (balances only when Finex is on). */ +function defaultPrivateStreams() { + const raw = process.env.RANGER_PRIVATE_STREAMS; + if (raw && String(raw).trim()) { + return String(raw) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + const streams = ["order", "trade"]; + if (process.env.RANGER_INCLUDE_BALANCES === "true") { + streams.push("balances"); + } + return streams; +} + +function publicOrigin(req) { + const proto = req.headers["x-forwarded-proto"] || req.protocol || "https"; + const host = req.headers["x-forwarded-host"] || req.headers.host; + const wsProto = String(proto).startsWith("https") ? "wss" : "ws"; + return { httpOrigin: `${proto}://${host}`, wsOrigin: `${wsProto}://${host}` }; +} + +function credentialsPayload(req) { + const { wsOrigin } = publicOrigin(req); + const streams = defaultPrivateStreams(); + return { + publicUrl: `${wsOrigin}/api/v2/ranger`, + privateWsUrl: `${wsOrigin}/api/1/ranger/ws`, + token: req.bffToken, + streams, + }; +} + +function cookieUpstreamHeaders(session) { + const headers = { Cookie: session.cookies || "" }; + if (config.upstreamHost) { + headers.Host = config.upstreamHost; + } + if (session.csrfToken) { + headers["X-CSRF-Token"] = session.csrfToken; + } + return headers; +} + +function buildUpstreamUrl(streamList, baseUrl) { + const streams = + streamList && streamList.length ? streamList : defaultPrivateStreams(); + const upstreamBase = String(baseUrl).replace(/\/+$/, ""); + return `${upstreamBase}/?stream=${[...streams].sort().join("&stream=")}`; +} + +/** + * Mint Peatio/Barong JWT via Barong auth endpoint (same as Envoy ext_authz). + * Private Rango expects Authorization — cookie-only WS through Envoy often 401s. + */ +async function fetchBarongJwt(session) { + const barongBase = String( + process.env.BARONG_URL || "http://barong:8001" + ).replace(/\/+$/, ""); + const headers = { + Cookie: session.cookies || "", + }; + if (session.csrfToken) { + headers["X-CSRF-Token"] = session.csrfToken; + } + + // Envoy path_prefix `/api/v2/auth` + request path `/api/v2/ranger/private` + const candidates = [ + "/api/v2/auth/api/v2/ranger/private", + "/api/v2/auth/api/v2/dena/account/balances", + "/api/v2/auth/", + ]; + + for (const path of candidates) { + try { + const res = await axios.get(`${barongBase}${path}`, { + headers, + validateStatus: () => true, + timeout: 5000, + }); + const auth = + res.headers.authorization || + res.headers.Authorization || + res.headers["authorization"]; + if (res.status === 200 && auth) { + return auth; + } + console.warn( + `[ranger-proxy] barong auth ${path} → ${res.status}`, + typeof res.data === "string" ? res.data.slice(0, 80) : "" + ); + } catch (err) { + console.warn(`[ranger-proxy] barong auth ${path} error:`, err.message); + } + } + return null; +} + +async function resolveUpstream(session, streamQ) { + const jwt = await fetchBarongJwt(session); + if (jwt) { + const base = + process.env.RANGER_PRIVATE_DIRECT_URL || + "ws://rango:8080/api/v2/ranger/private"; + return { + url: buildUpstreamUrl(streamQ, base), + headers: { Authorization: jwt }, + mode: "jwt-direct", + }; + } + + // Fallback: cookies through Envoy (needs FIBITEX_HOST + valid session) + const base = + process.env.RANGER_PRIVATE_URL || + config.rangerPrivateUrl || + "ws://gateway:8099/api/v2/ranger/private"; + return { + url: buildUpstreamUrl(streamQ, base), + headers: cookieUpstreamHeaders(session), + mode: "cookie-gateway", + }; +} + +router.get("/credentials", requireSession, (req, res) => { + return res.json(ok(credentialsPayload(req))); +}); + +router.get("/token", requireSession, (req, res) => { + return res.json(ok(credentialsPayload(req))); +}); + +function attachRangerPrivateProxy(server) { + const wss = new WebSocket.Server({ noServer: true }); + + server.on("upgrade", (req, socket, head) => { + const url = new URL(req.url || "", "http://localhost"); + if (!url.pathname.startsWith("/api/1/ranger/ws")) { + return; + } + + const token = + url.searchParams.get("token") || + extractToken({ + headers: { authorization: req.headers.authorization || "" }, + }); + + const streamsFromQuery = url.searchParams.getAll("stream"); + const streamQ = + streamsFromQuery.length > 0 ? streamsFromQuery : defaultPrivateStreams(); + + sessionStore + .get(token) + .then(async (session) => { + if (!session?.cookies) { + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + + let upstreamTarget; + try { + upstreamTarget = await resolveUpstream(session, streamQ); + } catch (err) { + console.error("[ranger-proxy] resolveUpstream failed:", err.message); + socket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + socket.destroy(); + return; + } + + console.log( + `[ranger-proxy] opening upstream mode=${upstreamTarget.mode} url=${upstreamTarget.url}` + ); + + let upstream; + try { + upstream = new WebSocket(upstreamTarget.url, { + headers: upstreamTarget.headers, + }); + } catch (err) { + console.error("[ranger-proxy] upstream construct failed:", err.message); + socket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + socket.destroy(); + return; + } + + const fail = (status, msg) => { + try { + upstream.terminate(); + } catch (_) { + /* ignore */ + } + try { + socket.write(`HTTP/1.1 ${status} ${msg}\r\n\r\n`); + socket.destroy(); + } catch (_) { + /* ignore */ + } + }; + + const openTimer = setTimeout(() => { + console.error("[ranger-proxy] upstream open timeout", upstreamTarget.url); + fail(504, "Gateway Timeout"); + }, 8000); + + upstream.once("open", () => { + clearTimeout(openTimer); + wss.handleUpgrade(req, socket, head, (clientWs) => { + const pipe = (src, dst, label) => { + src.on("message", (data) => { + if (dst.readyState === WebSocket.OPEN) dst.send(data); + }); + src.on("close", (code, reason) => { + console.warn( + `[ranger-proxy] ${label} closed`, + code, + reason && reason.toString ? reason.toString() : "" + ); + try { + dst.close(); + } catch (_) { + /* ignore */ + } + }); + src.on("error", (err) => { + console.error(`[ranger-proxy] ${label} error:`, err.message); + try { + dst.close(); + } catch (_) { + /* ignore */ + } + }); + }; + pipe(upstream, clientWs, "upstream"); + pipe(clientWs, upstream, "client"); + }); + }); + + upstream.once("error", (err) => { + clearTimeout(openTimer); + console.error( + "[ranger-proxy] upstream error before open:", + err.message, + upstreamTarget.url + ); + fail(502, "Bad Gateway"); + }); + + upstream.once("unexpected-response", (_req, res) => { + clearTimeout(openTimer); + console.error( + "[ranger-proxy] upstream unexpected response", + res.statusCode, + upstreamTarget.mode, + upstreamTarget.url + ); + fail(res.statusCode || 502, "Bad Gateway"); + }); + }) + .catch((err) => { + console.error("[ranger-proxy] session lookup failed:", err.message); + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + }); + }); +} + +module.exports = { + router, + attachRangerPrivateProxy, + defaultPrivateStreams, + buildUpstreamUrl, + cookieUpstreamHeaders, + fetchBarongJwt, +}; diff --git a/src/routes/trades.js b/src/routes/trades.js new file mode 100644 index 0000000..e25bf90 --- /dev/null +++ b/src/routes/trades.js @@ -0,0 +1,27 @@ +const express = require("express"); +const denaClient = require("../lib/denaClient"); +const { shahooMarketToDenaId } = require("../lib/mapOrder"); +const { mapDenaTradesToShahoo } = require("../lib/mapTrade"); +const { ok, okPaged } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +router.get("/recent/market/:market", async (req, res) => { + try { + const denaMarket = shahooMarketToDenaId(req.params.market); + if (!denaMarket) { + return res.status(400).json({ statusCode: 400, message: "Invalid market", content: null }); + } + const trades = await denaClient.getPublicTrades(denaMarket, { limit: 50 }); + return res.json(ok(mapDenaTradesToShahoo(trades))); + } catch { + return res.json(ok([])); + } +}); + +router.get("/self/:pageSize/:pageNumber", requireSession, async (_req, res) => { + return res.json(okPaged([], 0)); +}); + +module.exports = router; diff --git a/src/routes/transactions.js b/src/routes/transactions.js new file mode 100644 index 0000000..2bbf663 --- /dev/null +++ b/src/routes/transactions.js @@ -0,0 +1,73 @@ +const express = require("express"); +const denaClient = require("../lib/denaClient"); +const { getPrefs, updatePrefs } = require("../lib/userPrefs"); +const { + mapDenaTradeToTransaction, + mapDenaDepositToTransaction, + mapDenaWithdrawToTransaction, + mergeTransactions, + applyTransactionFilters, + splitSuccessAndFail, + paginateRows, +} = require("../lib/mapTransaction"); +const { okPaged, fail } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +async function loadTransactions(session) { + const [trades, deposits, withdrawals] = await Promise.all([ + denaClient.getMemberTrades(session, { limit: 500, page: 1 }).catch(() => []), + denaClient.getDeposits(session, { limit: 200, page: 1 }).catch(() => []), + denaClient.getWithdrawals(session, { limit: 200, page: 1 }).catch(() => []), + ]); + + const merged = mergeTransactions({ + trades: trades.map(mapDenaTradeToTransaction), + deposits: deposits.map(mapDenaDepositToTransaction), + withdrawals: withdrawals.map(mapDenaWithdrawToTransaction), + }); + + const prefs = await getPrefs(session); + const hidden = new Set((prefs.hiddenTransactionIds || []).map(String)); + return merged.filter((row) => !hidden.has(String(row.id))); +} + +router.get("/self/:pageSize/:pageNumber", requireSession, async (req, res) => { + try { + const rows = applyTransactionFilters(await loadTransactions(req.session), req.query); + const { success } = splitSuccessAndFail(rows); + const page = paginateRows(success, req.params.pageSize, req.params.pageNumber); + return res.json(okPaged(page.content, page.count)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +router.get("/fail/:pageSize/:pageNumber", requireSession, async (req, res) => { + try { + const rows = applyTransactionFilters(await loadTransactions(req.session), req.query); + const { fail: failed } = splitSuccessAndFail(rows); + const page = paginateRows(failed, req.params.pageSize, req.params.pageNumber); + return res.json(okPaged(page.content, page.count)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +router.delete("/:id", requireSession, async (req, res) => { + try { + const id = String(req.params.id); + const prefs = await getPrefs(req.session); + const hidden = new Set((prefs.hiddenTransactionIds || []).map(String)); + hidden.add(id); + await updatePrefs(req.session, { + hiddenTransactionIds: Array.from(hidden), + }); + return res.json({ statusCode: 200, message: "Transaction removed", content: null }); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +module.exports = router; diff --git a/src/routes/tts.js b/src/routes/tts.js new file mode 100644 index 0000000..76523e4 --- /dev/null +++ b/src/routes/tts.js @@ -0,0 +1,71 @@ +const express = require("express"); +const dalanClient = require("../lib/dalanClient"); +const { normalizeIranPhone } = require("../lib/phoneUtils"); +const { listRegions, listCities } = require("../lib/locationsStore"); +const { ok, fail } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +router.get("/regions", requireSession, async (_req, res) => { + return res.json(ok(listRegions())); +}); + +router.get("/cities/:regionId", requireSession, async (req, res) => { + return res.json(ok(listCities(req.params.regionId))); +}); + +router.post("/availableTime", requireSession, async (_req, res) => { + return res.json( + ok([ + { date: new Date().toISOString().slice(0, 10), slots: ["09:00", "11:00", "14:00", "16:00"] }, + ]) + ); +}); + +router.post("/setTime", requireSession, async (req, res) => { + try { + const phone = normalizeIranPhone(`${req.body?.areaCode || ""}${req.body?.phone || ""}`); + const result = await dalanClient.addLandlinePhone(req.session, phone); + if (result.status === 200) { + return res.json(ok({ message: "Landline verification started" })); + } + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/instant", requireSession, async (req, res) => { + try { + const phone = normalizeIranPhone(req.body?.phone_number || req.body?.phone); + const result = phone + ? await dalanClient.addLandlinePhone(req.session, phone) + : await dalanClient.addLandlinePhone(req.session, "+982100000000"); + if (result.status === 200) { + return res.json(ok({ message: "Verification call initiated" })); + } + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/validate", requireSession, async (req, res) => { + try { + const phone = normalizeIranPhone(req.body?.phone_number || req.body?.phone); + const code = req.body?.code || req.body?.verification_code; + const result = await dalanClient.verifyLandlineCode(req.session, phone, code); + if (result.status === 200) { + return res.json(ok({ message: "Landline verified" })); + } + const codeStatus = result.status >= 400 ? result.status : 422; + return res.status(codeStatus).json(fail(dalanClient.dalanErrorMessage(result.data), codeStatus)); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +module.exports = router; diff --git a/src/routes/users.js b/src/routes/users.js new file mode 100644 index 0000000..431386b --- /dev/null +++ b/src/routes/users.js @@ -0,0 +1,659 @@ +const express = require("express"); +const config = require("../config"); +const dalanClient = require("../lib/dalanClient"); +const denaClient = require("../lib/denaClient"); +const sessionStore = require("../lib/sessionStore"); +const { mapCurrentUserSummary } = require("../lib/mapUser"); +const { buildKycUser } = require("../lib/mapKyc"); +const { + mapDalanActivities, + dedupeDevices, + ACTIVITY_FILTER_OPTIONS, +} = require("../lib/mapActivity"); +const { mapReferralResponse } = require("../lib/mapReferral"); +const { mapLimitations, buildTradeFeeTiers, buildLevelsDetail } = require("../lib/mapVip"); +const { mapQrCodeResponse } = require("../lib/mapOtp"); +const { getPrefs } = require("../lib/userPrefs"); +const { ok, okPaged, fail } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +function parseShahooPage(pageSize, pageNumber) { + const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100)); + const page = Math.max(1, Number(pageNumber) + 1); + return { limit, page }; +} + +function parseActivityFilters(query) { + const filters = {}; + if (query.success === "true") filters.result = "succeed"; + if (query.success === "false") filters.result = "failed"; + + const from = query.from; + if (from) { + const ts = Math.floor(new Date(from).getTime() / 1000); + if (Number.isFinite(ts) && ts > 0) filters.timeFrom = ts; + } + + if (query.activity) filters.action = String(query.activity); + return filters; +} + +function applyActivityFilters(rows, filters) { + let result = rows; + if (filters.action) { + const action = filters.action.toLowerCase(); + result = result.filter( + (row) => + String(row.activity || "").toLowerCase() === action || + String(row.activity || "").toLowerCase().includes(action) + ); + } + return result; +} + +async function fetchActivityRows(session, topic, pageSize, pageNumber, query) { + const { limit, page } = parseShahooPage(pageSize, pageNumber); + const filters = parseActivityFilters(query); + const { items, total } = await dalanClient.getUserActivity(session, topic, { + limit, + page, + timeFrom: filters.timeFrom, + result: filters.result, + }); + const rows = applyActivityFilters(mapDalanActivities(items), filters); + return { rows, total: filters.action ? rows.length : total }; +} + +async function computeTradeVolume(session) { + const timeFrom = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60; + const trades = await denaClient.getMemberTrades(session, { + limit: 500, + page: 1, + timeFrom, + }); + return trades.reduce((sum, trade) => { + const total = parseFloat(trade.total) || 0; + return sum + total; + }, 0); +} + +function dalanRouteError(res, result, fallback = "Request failed") { + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data), code)); +} + +async function refreshSessionUser(session) { + try { + await dalanClient.getMe(session); + } catch { + /* keep cached user */ + } +} + +router.get("/2FA/activate", requireSession, async (req, res) => { + try { + const result = await dalanClient.generateOtpQrCode(req.session); + if (result.status === 200 || result.status === 201) { + return res.json(ok(mapQrCodeResponse(result.data))); + } + return dalanRouteError(res, result, "Unable to generate 2FA QR code"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.put("/2FA/confirm2FA", requireSession, async (req, res) => { + try { + const totp = req.body?.totp; + if (!totp) { + return res.status(422).json(fail("TOTP code is required", 422)); + } + + const hasOtp = Boolean(req.session.dalanUser?.otp); + const result = hasOtp + ? await dalanClient.disableOtp(req.session, totp) + : await dalanClient.enableOtp(req.session, totp); + + if (result.status === 200 || result.status === 201 || result.status === 204) { + req.session.otpPendingAction = hasOtp ? "DEACTIVATE" : "ACTIVATE"; + req.session.otpLastTotp = totp; + return res.json(ok({ success: true })); + } + return dalanRouteError(res, result, "Invalid verification code"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.put("/2FA/deActivate", requireSession, async (req, res) => { + try { + const totp = req.body?.totp; + if (!totp) { + return res.status(422).json(fail("TOTP code is required", 422)); + } + + let result = await dalanClient.disableOtpEmail(req.session, totp); + if (result.status !== 200 && result.status !== 201 && result.status !== 204) { + result = await dalanClient.disableOtp(req.session, totp); + } + + if (result.status === 200 || result.status === 201 || result.status === 204) { + req.session.otpPendingAction = null; + req.session.otpLastTotp = null; + await refreshSessionUser(req.session); + return res.json(ok({ success: true })); + } + return dalanRouteError(res, result, "Unable to deactivate 2FA"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/2FA/verify2faEmail", requireSession, async (req, res) => { + try { + const { token, twoFaAction } = req.body || {}; + if (!token) { + return res.status(422).json(fail("Verification code is required", 422)); + } + + const action = String(twoFaAction || req.session.otpPendingAction || "ACTIVATE").toUpperCase(); + const result = + action === "DEACTIVATE" + ? await dalanClient.disableOtpEmail(req.session, token) + : await dalanClient.enableOtpEmail(req.session, token); + + if (result.status === 200 || result.status === 201 || result.status === 204) { + req.session.otpPendingAction = null; + req.session.otpLastTotp = null; + await refreshSessionUser(req.session); + return res.json(ok({ success: true })); + } + return dalanRouteError(res, result, "Invalid email verification code"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.get("/2FA/resendVerifyEmail", requireSession, async (req, res) => { + try { + const hasOtp = Boolean(req.session.dalanUser?.otp); + let action = String(req.session.otpPendingAction || "").toUpperCase(); + if (!action) { + action = hasOtp ? "DEACTIVATE" : "ACTIVATE"; + } + + let result; + if (action === "DEACTIVATE") { + if (req.session.otpLastTotp) { + result = await dalanClient.disableOtp(req.session, req.session.otpLastTotp); + } else { + result = await dalanClient.disableOtp(req.session, ""); + } + } else if (req.session.otpLastTotp) { + result = await dalanClient.enableOtp(req.session, req.session.otpLastTotp); + } else { + result = await dalanClient.enableOtp(req.session, ""); + } + + if ( + result.status === 200 || + result.status === 201 || + result.status === 204 || + result.status === 422 + ) { + req.session.otpPendingAction = action; + return res.json(ok({ success: true })); + } + return dalanRouteError(res, result, "Unable to resend verification email"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/change", requireSession, async (req, res) => { + try { + const { oldPassword, password, confirm } = req.body || {}; + if (!oldPassword || !password) { + return res.status(422).json(fail("Password fields are required", 422)); + } + if (confirm && confirm !== password) { + return res.status(422).json(fail("Password confirmation does not match", 422)); + } + + req.session.pendingPasswordChange = { + oldPassword, + password, + confirm: confirm || password, + createdAt: Date.now(), + }; + + const result = await dalanClient.changePassword(req.session, { + oldPassword, + password, + confirmPassword: confirm || password, + }); + + if (result.status === 200 || result.status === 201 || result.status === 204) { + req.session.passwordChangeStep1Done = true; + return res.json(ok({ success: true })); + } + + if (result.status === 202) { + return res.json(ok({ success: true, pendingEmail: true })); + } + + req.session.pendingPasswordChange = null; + req.session.passwordChangeStep1Done = null; + return dalanRouteError(res, result, "Unable to change password"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/change/check", requireSession, async (req, res) => { + try { + const { token } = req.body || {}; + const pending = req.session.pendingPasswordChange; + + if (req.session.passwordChangeStep1Done) { + req.session.pendingPasswordChange = null; + req.session.passwordChangeStep1Done = null; + await refreshSessionUser(req.session); + return res.json(ok({ success: true })); + } + + if (!pending) { + return res.status(422).json(fail("No pending password change", 422)); + } + + if (!token) { + return res.status(422).json(fail("Verification code is required", 422)); + } + + const result = await dalanClient.changePassword(req.session, { + oldPassword: pending.oldPassword, + password: pending.password, + confirmPassword: pending.confirm, + }); + + if (result.status === 200 || result.status === 201 || result.status === 204) { + req.session.pendingPasswordChange = null; + await refreshSessionUser(req.session); + return res.json(ok({ success: true })); + } + + return dalanRouteError(res, result, "Invalid verification code"); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/authenticate", async (req, res) => { + try { + const { email, password, otpCode, otp_code, totp } = req.body || {}; + const result = await dalanClient.login({ + email, + password, + otpCode: otpCode || otp_code || totp, + captcha: req.body?.captcha, + }); + + if (result.needs2fa) { + return res.json(ok({ token: null }, 203)); + } + + return res.json(ok({ token: result.token })); + } catch (err) { + const code = err.statusCode || 401; + return res.status(code).json(fail(err.message || "Login failed", code)); + } +}); + +router.post("/logout", requireSession, async (req, res) => { + try { + await dalanClient.logout(req.session); + sessionStore.remove(req.bffToken); + return res.json(ok({ success: true })); + } catch { + sessionStore.remove(req.bffToken); + return res.json(ok({ success: true })); + } +}); + +router.get("/currentUserSummary", requireSession, async (req, res) => { + try { + let dalanUser = req.session.dalanUser; + try { + dalanUser = await dalanClient.getMe(req.session); + } catch (err) { + if (!dalanUser) throw err; + } + const user = mapCurrentUserSummary(dalanUser, { + theme: req.session.theme, + setting: { + theme: req.session.theme || "LIGHT", + layout: req.session.layout || "template2", + }, + }); + return res.json(ok(user)); + } catch (err) { + const code = err.statusCode || 401; + return res.status(code).json(fail(err.message || "Unauthorized", code)); + } +}); + +router.get("/currentUser", requireSession, async (req, res) => { + try { + const dalanUser = await dalanClient.getMe(req.session); + return res.json( + ok( + mapCurrentUserSummary(dalanUser, { + theme: req.session.theme, + setting: { + theme: req.session.theme || "LIGHT", + layout: req.session.layout || "template2", + }, + }) + ) + ); + } catch (err) { + const code = err.statusCode || 401; + return res.status(code).json(fail(err.message || "Unauthorized", code)); + } +}); + +router.get("/profile", requireSession, async (req, res) => { + try { + const [dalanUser, documents, treasuries] = await Promise.all([ + dalanClient.getFullUser(req.session), + dalanClient.getDocuments(req.session), + dalanClient.getTreasuries(req.session), + ]); + return res.json(ok(buildKycUser(dalanUser, { documents, treasuries }))); + } catch (err) { + const code = err.statusCode || 401; + return res.status(code).json(fail(err.message || "Unauthorized", code)); + } +}); + +router.get("/referrer", requireSession, async (req, res) => { + try { + const dalanUser = await dalanClient.getMe(req.session); + const uid = dalanUser?.uid; + if (!uid) { + return res.status(401).json(fail("Unauthorized", 401)); + } + + const [bonus, report] = await Promise.all([ + denaClient.getBonus(req.session), + denaClient.getBonusReport(req.session, { limit: 10, page: 1 }), + ]); + + const publicUrl = + req.get("origin") || req.get("referer")?.split("/").slice(0, 3).join("/") || config.publicUrl; + + return res.json( + ok( + mapReferralResponse({ + uid, + publicUrl, + bonus, + report, + }) + ) + ); + } catch (err) { + const code = err.statusCode || 500; + return res.status(code).json(fail(err.message || "Request failed", code)); + } +}); + +router.get("/login/info/:pageSize/:pageNumber", requireSession, async (req, res) => { + try { + const hasFilters = + req.query.success || req.query.from || req.query.activity || req.query.period; + const topic = hasFilters ? "all" : "session"; + const { rows, total } = await fetchActivityRows( + req.session, + topic, + req.params.pageSize, + req.params.pageNumber, + req.query + ); + return res.json(okPaged(rows, total)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +router.get("/device/info/:pageSize/:pageNumber", requireSession, async (req, res) => { + try { + const { rows, total } = await fetchActivityRows( + req.session, + "session", + req.params.pageSize, + req.params.pageNumber, + req.query + ); + const devices = dedupeDevices(rows); + return res.json(okPaged(devices, devices.length || total)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +router.get("/activities", requireSession, async (_req, res) => { + return res.json(ok(ACTIVITY_FILTER_OPTIONS)); +}); + +router.get("/setting/favorite/currency", requireSession, async (req, res) => { + try { + const prefs = await getPrefs(req.session); + const content = (prefs.favoriteCurrencies || []).map((currencyName) => ({ + currencyName, + favorite: true, + })); + return res.json(ok(content)); + } catch { + return res.json(ok([])); + } +}); + +router.put("/setting", requireSession, async (req, res) => { + const body = req.body || {}; + const theme = body.theme || req.session.theme || "LIGHT"; + const layout = body.layout || req.session.layout || "template2"; + req.session.theme = theme; + req.session.layout = layout; + req.session.whitelistEnable = + typeof body.whitelistEnable === "boolean" + ? body.whitelistEnable + : req.session.whitelistEnable; + const user = mapCurrentUserSummary(req.session.dalanUser, { + theme, + setting: { + ...body, + theme, + layout, + whitelistEnable: Boolean(req.session.whitelistEnable), + }, + }); + return res.json(ok(user)); +}); + +router.post("/setting/validate", requireSession, async (req, res) => { + try { + const token = req.body?.token; + if (!token) { + return res.status(422).json(fail("token is required", 422)); + } + + const result = await dalanClient.verifyOtpCode(req.session, token); + if (result.status !== 200) { + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data), code)); + } + + req.session.whitelistEnable = Boolean(req.body?.whitelistEnable); + const user = mapCurrentUserSummary(req.session.dalanUser, { + setting: { + ...(req.body || {}), + whitelistEnable: req.session.whitelistEnable, + }, + }); + return res.json(ok(user)); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.get("/limitations", requireSession, async (req, res) => { + try { + const [levels, tradingFees, tradeVolume] = await Promise.all([ + denaClient.getLevels(req.session), + denaClient.getTradingFees(), + computeTradeVolume(req.session), + ]); + return res.json( + ok( + mapLimitations({ + levels, + tradeVolume, + tradingFees, + }) + ) + ); + } catch { + const tiers = buildTradeFeeTiers([]); + return res.json( + ok({ + tradeVolume: 0, + tradeFee: 0.35, + makerFee: 0.35, + currentLevelMin: 0, + nextLevelMin: tiers.tradeFeeMonthVolTh[1] || 0, + levelIndex: 0, + currentLevel: "vip-0", + levelsDetail: buildLevelsDetail(tiers, 0), + ...tiers, + }) + ); + } +}); + +router.post("/activate/token", async (req, res) => { + try { + const { email, token, code, captcha } = req.body || {}; + const verification = String(token || code || "").trim(); + + if (!verification) { + return res.status(422).json(fail("Verification code is required", 422)); + } + + const looksLikeEmailLinkToken = + verification.includes(".") && verification.length > 32; + + let result; + if (looksLikeEmailLinkToken) { + result = await dalanClient.confirmEmailByLinkToken({ + token: verification, + captcha, + }); + } else { + if (!email) { + return res.status(422).json(fail("Email is required", 422)); + } + result = await dalanClient.confirmEmailActivation({ + email, + code: verification, + captcha, + }); + } + + if (result.status === 200 || result.status === 201) { + const session = dalanClient.createSessionFromAuthResponse(result); + return res.json(ok({ token: session.token })); + } + + const errCode = result.status >= 400 ? result.status : 422; + return res + .status(errCode) + .json(fail(dalanClient.dalanErrorMessage(result.data), errCode)); + } catch (err) { + return res.status(500).json(fail(err.message || "Activation failed", 500)); + } +}); + +router.post("/register", async (req, res) => { + try { + const { email, password, refid, captcha } = req.body || {}; + const result = await dalanClient.register({ email, password, refid, captcha }); + if (result.status === 201 || result.status === 200) { + return res.json({ + statusCode: 200, + message: "Registration successful", + content: { email }, + }); + } + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Registration failed", 500)); + } +}); + +router.post("/forgot/password", async (req, res) => { + try { + const { email, captcha } = req.body || {}; + const result = await dalanClient.forgotPassword({ email, captcha }); + if (result.status === 201) { + return res.json({ statusCode: 200, message: "Reset code sent", content: null }); + } + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(dalanClient.dalanErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/forgot/check", async (req, res) => { + try { + const { email, code, token, captcha } = req.body || {}; + const result = await dalanClient.confirmForgotCode({ + email, + code: code || token, + captcha, + }); + if (result.status === 200) { + return res.json({ statusCode: 200, message: "Code verified", content: null }); + } + const errCode = result.status >= 400 ? result.status : 422; + return res.status(errCode).json(fail(dalanClient.dalanErrorMessage(result.data), errCode)); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.post("/forgot/change", async (req, res) => { + try { + const { email, code, password, confirmPassword, captcha } = req.body || {}; + const result = await dalanClient.resetPassword({ + email, + code, + password, + confirmPassword, + captcha, + }); + if (result.status === 201) { + return res.json({ statusCode: 200, message: "Password updated", content: null }); + } + const errCode = result.status >= 400 ? result.status : 422; + return res.status(errCode).json(fail(dalanClient.dalanErrorMessage(result.data), errCode)); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +module.exports = router; diff --git a/src/routes/wallets.js b/src/routes/wallets.js new file mode 100644 index 0000000..a81b788 --- /dev/null +++ b/src/routes/wallets.js @@ -0,0 +1,135 @@ +const express = require("express"); +const denaClient = require("../lib/denaClient"); +const { mapDenaBalancesToWallets } = require("../lib/mapMarket"); +const { getPrefs, updatePrefs, normalizeCurrencyCode } = require("../lib/userPrefs"); +const { denaErrorMessage } = require("../lib/mapOrder"); +const { ok, fail } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +async function walletsWithFavorites(session, balances) { + const prefs = await getPrefs(session); + const favorites = new Set((prefs.favoriteCurrencies || []).map(normalizeCurrencyCode)); + const now = new Date().toISOString(); + + return mapDenaBalancesToWallets(balances).map((wallet) => ({ + ...wallet, + favorite: favorites.has(wallet.currencyName), + updated: now, + })); +} + +router.get("/currencies", async (_req, res) => { + const markets = await denaClient.getMarkets(); + const currencies = {}; + markets.forEach((m) => { + if (m.base_unit) currencies[m.base_unit.toUpperCase()] = true; + if (m.quote_unit) currencies[m.quote_unit.toUpperCase()] = true; + }); + return res.json(ok(Object.keys(currencies).map((c) => ({ name: c })))); +}); + +router.get("/self", requireSession, async (req, res) => { + try { + const balances = await denaClient.getBalances(req.session); + return res.json(ok(await walletsWithFavorites(req.session, balances))); + } catch { + return res.json(ok([])); + } +}); + +router.get("/self/balance", requireSession, async (req, res) => { + try { + const balances = await denaClient.getBalances(req.session); + return res.json(ok(await walletsWithFavorites(req.session, balances))); + } catch { + return res.json(ok([])); + } +}); + +router.get("/totalBalances", requireSession, async (req, res) => { + try { + const balances = await denaClient.getBalances(req.session); + const wallets = await walletsWithFavorites(req.session, balances); + const totalBalance = wallets.reduce((sum, row) => sum + (row.totalBalance || 0), 0); + return res.json(ok({ totalBalance, wallets })); + } catch { + return res.json(ok({ totalBalance: 0, wallets: [] })); + } +}); + +router.get("/self/currency/:currencyName", requireSession, async (req, res) => { + try { + const balances = await denaClient.getBalances(req.session); + const wallets = await walletsWithFavorites(req.session, balances); + const currency = String(req.params.currencyName || "").toUpperCase(); + const wallet = wallets.find((w) => w.currencyName === currency); + return res.json(ok(wallet || null)); + } catch { + return res.json(ok(null)); + } +}); + +router.put("/update/favorite", requireSession, async (req, res) => { + try { + const currencyName = normalizeCurrencyCode( + req.body?.currencyName || req.body?.currency + ); + const favorite = Boolean(req.body?.favorite); + + if (!currencyName) { + return res.status(422).json(fail("currencyName is required", 422)); + } + + const prefs = await getPrefs(req.session); + const favorites = new Set((prefs.favoriteCurrencies || []).map(normalizeCurrencyCode)); + const markets = new Set((prefs.favoriteMarkets || []).map(normalizeCurrencyCode)); + + if (favorite) { + favorites.add(currencyName); + markets.add(currencyName); + } else { + favorites.delete(currencyName); + markets.delete(currencyName); + } + + await updatePrefs(req.session, { + favoriteCurrencies: Array.from(favorites), + favoriteMarkets: Array.from(markets), + }); + + return res.json({ + statusCode: 200, + message: favorite ? "Added to favorites" : "Removed from favorites", + content: { currencyName, favorite }, + }); + } catch (err) { + return res.status(500).json(fail(err.message || "Request failed", 500)); + } +}); + +router.put("/address", requireSession, async (req, res) => { + try { + const currencyName = req.body?.currencyName || req.body?.currency; + if (!currencyName) { + return res.status(400).json(fail("currencyName is required", 400)); + } + + const result = await denaClient.getDepositAddress( + req.session, + String(currencyName).toLowerCase() + ); + + if (result.status === 200 && result.data?.address) { + return res.json(ok(result.data.address)); + } + + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Failed to create address", 500)); + } +}); + +module.exports = router; diff --git a/src/routes/withdrawals.js b/src/routes/withdrawals.js new file mode 100644 index 0000000..d57a77b --- /dev/null +++ b/src/routes/withdrawals.js @@ -0,0 +1,157 @@ +const express = require("express"); +const denaClient = require("../lib/denaClient"); +const { mapDenaWithdrawal } = require("../lib/mapWithdrawal"); +const { denaErrorMessage } = require("../lib/mapOrder"); +const { ok, okPaged, fail } = require("../lib/shahooResponse"); +const { requireSession } = require("../middleware/auth"); + +const router = express.Router(); + +function parsePage(pageSize, pageNumber) { + const limit = Math.max(1, Math.min(Number(pageSize) || 10, 100)); + const page = Math.max(1, Number(pageNumber) + 1); + return { limit, page }; +} + +function mapCreateBody(body = {}) { + return { + currency: body.currencyName || body.currency, + amount: body.amount, + rid: body.destinationWalletAddress || body.destWallet || body.rid, + otp: body.totp || body.otp, + paymentId: body.destTag || body.payment_id, + beneficiaryId: body.beneficiaryId || body.beneficiary_id, + note: body.note, + }; +} + +async function listWithdrawals(session, { currency, limit, page }) { + const withdrawals = await denaClient.getWithdrawals(session, { limit, page }); + const mapped = withdrawals.map(mapDenaWithdrawal).filter(Boolean); + if (!currency) return mapped; + const code = String(currency).toUpperCase(); + return mapped.filter((row) => row.currencyName === code); +} + +router.post("/", requireSession, async (req, res) => { + try { + const payload = mapCreateBody(req.body); + if (!payload.currency || !payload.amount) { + return res.status(422).json(fail("currencyName and amount are required", 422)); + } + if (!payload.rid && !payload.beneficiaryId) { + return res.status(422).json(fail("destinationWalletAddress is required", 422)); + } + + const result = await denaClient.createWithdraw(req.session, payload); + if (result.status === 201 || result.status === 200) { + return res.json(ok(mapDenaWithdrawal(result.data))); + } + + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Withdraw failed", 500)); + } +}); + +router.get("/:id/confirm/:token", requireSession, async (req, res) => { + try { + const result = await denaClient.confirmWithdraw( + req.session, + req.params.id, + req.params.token + ); + + if (result.status === 200 || result.status === 201 || result.status === 204) { + const mapped = mapDenaWithdrawal(result.data) || + mapDenaWithdrawal(await denaClient.getWithdrawById(req.session, req.params.id)); + return res.json(ok(mapped, 200)); + } + + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Confirm failed", 500)); + } +}); + +router.post("/confirm", requireSession, async (req, res) => { + try { + const id = req.body?.id; + const token = req.body?.token; + if (!id || !token) { + return res.status(422).json(fail("id and token are required", 422)); + } + + const result = await denaClient.confirmWithdraw(req.session, id, token); + if (result.status === 200 || result.status === 201 || result.status === 204) { + const mapped = mapDenaWithdrawal(result.data) || + mapDenaWithdrawal(await denaClient.getWithdrawById(req.session, id)); + return res.json(ok(mapped)); + } + + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Confirm failed", 500)); + } +}); + +router.delete("/cancel/:id", requireSession, async (req, res) => { + try { + const result = await denaClient.cancelWithdraw(req.session, req.params.id); + if (result.status === 200 || result.status === 201 || result.status === 204) { + return res.json(ok({ success: true })); + } + + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Cancel failed", 500)); + } +}); + +router.get("/resendMail/:id", requireSession, async (req, res) => { + try { + const result = await denaClient.resendWithdrawEmail(req.session, req.params.id); + if (result.status === 200 || result.status === 201 || result.status === 204) { + return res.json({ statusCode: 200, message: "Verification email sent", content: null }); + } + + const code = result.status >= 400 ? result.status : 422; + return res.status(code).json(fail(denaErrorMessage(result.data), code)); + } catch (err) { + return res.status(500).json(fail(err.message || "Resend failed", 500)); + } +}); + +router.get("/self/:currency/:pageSize/:pageNumber", requireSession, async (req, res) => { + try { + const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber); + const content = await listWithdrawals(req.session, { + currency: req.params.currency, + limit, + page, + }); + return res.json(okPaged(content, content.length)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +router.get("/self/:pageSize/:pageNumber", requireSession, async (req, res) => { + if (!/^\d+$/.test(String(req.params.pageSize))) { + return res.status(404).json(fail("Not found", 404)); + } + + try { + const { limit, page } = parsePage(req.params.pageSize, req.params.pageNumber); + const content = await listWithdrawals(req.session, { limit, page }); + return res.json(okPaged(content, content.length)); + } catch { + return res.json(okPaged([], 0)); + } +}); + +module.exports = router; diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..bc822ce --- /dev/null +++ b/yarn.lock @@ -0,0 +1,780 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@ioredis/commands@1.10.0": + version "1.10.0" + resolved "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz" + integrity sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q== + +accepts@~1.3.8: + 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== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +agent-base@6: + version "6.0.2" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +append-field@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz" + integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +axios@^1.6.8: + version "1.18.1" + resolved "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz" + integrity sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g== + dependencies: + follow-redirects "^1.16.0" + form-data "^4.0.5" + https-proxy-agent "^5.0.1" + proxy-from-env "^2.1.0" + +body-parser@~1.20.5: + version "1.20.5" + resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz" + integrity sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA== + 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" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +busboy@^1.0.0: + version "1.6.0" + resolved "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + +bytes@~3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + 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== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +cluster-key-slot@1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz" + integrity sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw== + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +concat-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +content-disposition@~0.5.4: + version "0.5.4" + resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4, content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie-signature@~1.0.6: + version "1.0.7" + resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz" + integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA== + +cookie@~0.7.1: + version "0.7.2" + resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +cors@^2.8.5: + version "2.8.6" + resolved "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz" + integrity sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw== + dependencies: + object-assign "^4" + vary "^1" + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@4: + version "4.4.3" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +debug@4.4.3: + version "4.4.3" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +denque@2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz" + integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== + +depd@~2.0.0, depd@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@~1.2.0, destroy@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +dotenv@^16.4.5: + version "16.6.1" + resolved "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz" + integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow== + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.2" + resolved "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz" + integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +express@^4.19.2: + version "4.22.2" + resolved "https://registry.npmjs.org/express/-/express-4.22.2.tgz" + integrity sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "~1.20.5" + 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.15.1" + 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" + +finalhandler@~1.3.1: + version "1.3.2" + resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz" + integrity sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg== + 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" + +follow-redirects@^1.16.0: + version "1.16.0" + resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== + +form-data@^4.0.5: + version "4.0.6" + resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz" + integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.4" + mime-types "^2.1.35" + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@~0.5.2: + version "0.5.2" + resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + 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" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2, hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + +http-errors@~2.0.0, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + dependencies: + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + +https-proxy-agent@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + +iconv-lite@~0.4.24: + version "0.4.24" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +inherits@^2.0.3, inherits@~2.0.3, inherits@~2.0.4: + version "2.0.4" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ioredis@^5.4.1: + version "5.11.1" + resolved "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz" + integrity sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A== + dependencies: + "@ioredis/commands" "1.10.0" + cluster-key-slot "1.1.1" + debug "4.4.3" + denque "2.1.0" + redis-errors "1.2.0" + redis-parser "3.0.0" + standard-as-callback "2.1.0" + +ipaddr.js@1.9.1: + 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== + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +merge-descriptors@1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.35, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mkdirp@^0.5.4: + version "0.5.6" + resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +ms@^2.1.3, ms@2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +multer@^1.4.5-lts.1: + version "1.4.5-lts.2" + resolved "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz" + integrity sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A== + dependencies: + append-field "^1.0.0" + busboy "^1.0.0" + concat-stream "^1.5.2" + mkdirp "^0.5.4" + object-assign "^4.1.1" + type-is "^1.6.4" + xtend "^4.0.0" + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +object-assign@^4, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +on-finished@~2.4.1: + version "2.4.1" + resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-to-regexp@~0.1.12: + 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== + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +proxy-addr@~2.0.7: + 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== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +proxy-from-env@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz" + integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== + +qs@~6.15.1: + version "6.15.3" + resolved "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz" + integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A== + dependencies: + es-define-property "^1.0.1" + side-channel "^1.1.1" + +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@~2.5.3: + version "2.5.3" + resolved "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz" + integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.4.24" + unpipe "~1.0.0" + +readable-stream@^2.2.2: + version "2.3.8" + resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +redis-errors@^1.0.0, redis-errors@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz" + integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w== + +redis-parser@3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz" + integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A== + dependencies: + redis-errors "^1.0.0" + +safe-buffer@~5.1.0: + version "5.1.2" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-buffer@5.2.1: + version "5.2.1" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +send@~0.19.0, send@~0.19.1: + version "0.19.2" + resolved "https://registry.npmjs.org/send/-/send-0.19.2.tgz" + integrity sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg== + 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" + +serve-static@~1.16.2: + version "1.16.3" + resolved "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz" + integrity sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA== + dependencies: + encodeurl "~2.0.0" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "~0.19.1" + +setprototypeof@~1.2.0, setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +side-channel-list@^1.0.1: + 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== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + +side-channel-map@^1.0.1: + 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== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + 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" + +side-channel@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +standard-as-callback@2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz" + integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== + +statuses@~2.0.1, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +toidentifier@~1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +type-is@^1.6.4, type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + +unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +uuid@^9.0.1: + version "9.0.1" + resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== + +vary@^1, vary@~1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +ws@^8.21.1: + version "8.21.1" + resolved "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz" + integrity sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==