Initial commit

This commit is contained in:
Yaser
2026-08-13 20:18:11 +03:30
commit b676ac6288
70 changed files with 8810 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.env
.git
npm-debug.log*
yarn-debug.log*
yarn-error.log*

27
.env.example Normal file
View File

@@ -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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.env
.env.local
package-lock.json

23
Dockerfile Normal file
View File

@@ -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"]

83
README.md Normal file
View File

@@ -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.<domain>/api/1` → BFF, `shahoo.<domain>/` → Shahoo SPA.
## Git
Separate repo on branch `Update2026` (see `Docs/agents/mandatory-rules.md`).

1701
data/dena-swagger.json Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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");

View File

@@ -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");

View File

@@ -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)");

46
src/config.js Normal file
View File

@@ -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",
};

46
src/index.js Normal file
View File

@@ -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`);
});

295
src/lib/buildApiSpec.js Normal file
View File

@@ -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 };

423
src/lib/dalanClient.js Normal file
View File

@@ -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,
};

520
src/lib/denaClient.js Normal file
View File

@@ -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,
};

87
src/lib/jibitClient.js Normal file
View File

@@ -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,
};

55
src/lib/locationsStore.js Normal file
View File

@@ -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,
};

55
src/lib/mapActivity.js Normal file
View File

@@ -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,
};

View File

@@ -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,
};

41
src/lib/mapBeneficiary.js Normal file
View File

@@ -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,
};

43
src/lib/mapConstants.js Normal file
View File

@@ -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 };

123
src/lib/mapKyc.js Normal file
View File

@@ -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,
};

132
src/lib/mapMarket.js Normal file
View File

@@ -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,

View File

@@ -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 };

View File

@@ -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,
};

76
src/lib/mapOhlcv.js Normal file
View File

@@ -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,
};

108
src/lib/mapOrder.js Normal file
View File

@@ -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,
};

25
src/lib/mapOtp.js Normal file
View File

@@ -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 };

35
src/lib/mapReferral.js Normal file
View File

@@ -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,
};

23
src/lib/mapTrade.js Normal file
View File

@@ -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 };

174
src/lib/mapTransaction.js Normal file
View File

@@ -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,
};

40
src/lib/mapUser.js Normal file
View File

@@ -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 };

108
src/lib/mapVip.js Normal file
View File

@@ -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,
};

79
src/lib/mapWithdrawal.js Normal file
View File

@@ -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,
};

15
src/lib/phoneUtils.js Normal file
View File

@@ -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 };

67
src/lib/sessionStore.js Normal file
View File

@@ -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 };

13
src/lib/shahooResponse.js Normal file
View File

@@ -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 };

View File

@@ -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 };

114
src/lib/userPrefs.js Normal file
View File

@@ -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,
};

37
src/middleware/auth.js Normal file
View File

@@ -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 };

8
src/middleware/upload.js Normal file
View File

@@ -0,0 +1,8 @@
const multer = require("multer");
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
});
module.exports = { upload };

94
src/routes/alerts.js Normal file
View File

@@ -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;

69
src/routes/api.js Normal file
View File

@@ -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;

150
src/routes/apiKeys.js Normal file
View File

@@ -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;

View File

@@ -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;

59
src/routes/bank.js Normal file
View File

@@ -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;

20
src/routes/constants.js Normal file
View File

@@ -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;

76
src/routes/deposits.js Normal file
View File

@@ -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;

View File

@@ -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;

95
src/routes/docs.js Normal file
View File

@@ -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;

48
src/routes/doneOrders.js Normal file
View File

@@ -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;

9
src/routes/health.js Normal file
View File

@@ -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;

130
src/routes/irtpays.js Normal file
View File

@@ -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;

51
src/routes/jibit.js Normal file
View File

@@ -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;

108
src/routes/kycUsers.js Normal file
View File

@@ -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;

16
src/routes/locations.js Normal file
View File

@@ -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;

23
src/routes/market.js Normal file
View File

@@ -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;

27
src/routes/markets.js Normal file
View File

@@ -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;

114
src/routes/notifications.js Normal file
View File

@@ -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;

82
src/routes/ohlcvs.js Normal file
View File

@@ -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;

143
src/routes/orders.js Normal file
View File

@@ -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;

89
src/routes/otp.js Normal file
View File

@@ -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;

285
src/routes/ranger.js Normal file
View File

@@ -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,
};

27
src/routes/trades.js Normal file
View File

@@ -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;

View File

@@ -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;

71
src/routes/tts.js Normal file
View File

@@ -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;

659
src/routes/users.js Normal file
View File

@@ -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;

135
src/routes/wallets.js Normal file
View File

@@ -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;

157
src/routes/withdrawals.js Normal file
View File

@@ -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;

780
yarn.lock Normal file
View File

@@ -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==