diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35e3a39..3f82225 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: backend: - name: Backend — Build & Test + name: "Backend — Build & Test" runs-on: ubuntu-latest defaults: run: @@ -36,7 +36,7 @@ jobs: LOG_LEVEL: error frontend-demo: - name: Frontend Demo — Build + name: "Frontend Demo — Build & Test" runs-on: ubuntu-latest defaults: run: @@ -53,9 +53,11 @@ jobs: - run: npm ci - run: npm run build + - name: frontend tests + run: npm run test frontend-admin: - name: Frontend Admin — Build + name: "Frontend Admin — Build & Test" runs-on: ubuntu-latest defaults: run: @@ -72,9 +74,11 @@ jobs: - run: npm ci - run: npm run build + - name: frontend tests + run: npm run test sdk: - name: SDK — Build & Test + name: "SDK — Build & Test" runs-on: ubuntu-latest defaults: run: @@ -92,3 +96,4 @@ jobs: - run: npm ci - run: npm run build - run: npm test + diff --git a/.gitignore b/.gitignore index 4c7956c..62f8d9a 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,9 @@ frontend-demo/dist/ frontend-admin/dist/ *.tsbuildinfo + + + +docs/roadmap-priority.md + +docs/core_business_audit_report.md \ No newline at end of file diff --git a/README.md b/README.md index 36285e9..ea1ba33 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ [![Vitest](https://img.shields.io/badge/Vitest-1.x-6B9DF8?logo=vitest)](https://vitest.io/) [![CI/CD](https://img.shields.io/badge/CI%2FCD-GitHub%20Actions-2088FF?logo=githubactions)](https://github.com/easyshellworld/bridgeshield/actions) -[![Tests](https://img.shields.io/badge/tests-88%20%2B%2021-brightgreen)](https://github.com/easyshellworld/bridgeshield/actions) +[![Tests](https://img.shields.io/badge/tests-138%20%2B%2021%20%2B%2038-brightgreen)](https://github.com/easyshellworld/bridgeshield/actions) [![Docker Ready](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker)](https://www.docker.com/) BridgeShield is an Anti-Money Laundering (AML) compliance gateway designed specifically for cross-chain trading platforms like LI.FI. It provides real-time risk assessment, transaction monitoring, and regulatory compliance for decentralized finance (DeFi) transactions. @@ -91,6 +91,7 @@ import { BridgeShieldClient } from '@bridgeshield/sdk'; const client = new BridgeShieldClient({ baseUrl: 'https://api.bridgeshield.io', + apiKey: process.env.BRIDGESHIELD_API_KEY, }); const result = await client.checkAddress({ @@ -228,15 +229,17 @@ bridgeshield/ | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/v1/health` | Health check with service status | -| POST | `/api/v1/aml/check` | Check address risk score | -| GET | `/api/v1/aml/whitelist` | Get whitelist summary | -| POST | `/api/v1/aml/appeal` | Submit appeal for flagged address | -| GET | `/api/v1/aml/appeal/status/:ticketId` | Check appeal status | +| POST | `/api/v1/aml/check` | Check address risk score (**API Key required**) | +| GET | `/api/v1/aml/whitelist` | Get whitelist summary (**API Key required**) | +| POST | `/api/v1/aml/appeal` | Submit appeal for flagged address (**API Key required**) | +| GET | `/api/v1/aml/appeal/status/:ticketId` | Check appeal status (**API Key required**) | ### Admin API (Port 3000) | Method | Endpoint | Description | |--------|----------|-------------| +| POST | `/api/v1/admin/auth/login` | Admin login (username/password -> JWT) | +| GET | `/api/v1/admin/auth/me` | Get current admin session | | GET | `/api/v1/admin/dashboard/stats` | Dashboard statistics | | GET | `/api/v1/admin/dashboard/risk-trend` | 7-day risk trend | | GET | `/api/v1/admin/dashboard/risk-distribution` | Risk level distribution | @@ -257,6 +260,23 @@ bridgeshield/ | GET | `/api/v1/earn/portfolio/:wallet` | Proxy wallet portfolio positions | | GET | `/api/v1/composer/quote` | AML-gated Composer quote (`BLOCK`/`REVIEW`/`ALLOW`) | | GET | `/api/v1/behavior/profile/:wallet` | C-end wallet behavior profile and anomaly signals | +| GET | `/api/v1/analytics/transfers` | LI.FI cross-chain transaction history for investigation | + +### LI.FI Analytics Integration + +BridgeShield enhances AML decision-making by integrating **LI.FI Analytics API** for cross-chain transaction history: + +``` +Address Check → [LI.FI Analytics History] + [Local checkLog] → Enhanced Behavior Analysis → AML Decision +``` + +**Enhanced Risk Signals from LI.FI:** +- **High-risk address interactions** — Detects if the address has transacted with known mixer/sanctioned addresses +- **Cross-chain tumbling patterns** — Identifies suspicious chain-hopping behavior +- **Amount spike detection** — Compares current transaction against LI.FI historical averages +- **First-time LI.FI user high-value detection** — Flags new addresses with large transactions + +**Combined Confidence:** When both local BridgeShield history and LI.FI history are available, behavior analysis confidence increases to HIGH. ## 🛡️ Features @@ -265,7 +285,10 @@ bridgeshield/ - **Real-time Scoring:** Risk score 0-100 with HIGH/MEDIUM/LOW classification - **Risk Factors:** Detailed breakdown of risk indicators - **Caching:** Multi-tier in-memory caching with TTL -- **Behavior Analytics:** C-end behavior anomaly detection (velocity, chain novelty, amount spikes, decision drift) +- **Behavior Analytics:** C-end behavior anomaly detection enhanced with LI.FI cross-chain history + - Local checkLog history for BridgeShield-observed transactions + - LI.FI Analytics API for complete cross-chain transaction history + - Combined signals: velocity, chain novelty, amount spikes, decision drift, high-risk interactions ### Compliance Tools - **Appeal System:** Users can contest flagged addresses @@ -281,18 +304,25 @@ bridgeshield/ ## 🧪 Testing -### Backend Tests +### Backend Tests (138 tests) ```bash cd backend -npm test # Run all tests (88 tests) +npm test # Run all tests (138 tests) ``` -### SDK Tests +### SDK Tests (21 tests) ```bash cd packages/sdk npm test # Run SDK tests (21 tests) ``` +### Frontend Tests (38 tests) +```bash +cd frontend-demo && npm test # 19 tests +cd frontend-admin && npm test # 19 tests +``` +> **Note:** Frontend tests are skipped in CI. Run locally for development. + ### Frontend Builds ```bash cd frontend-demo && npm run build @@ -337,13 +367,19 @@ docker-compose logs -f backend - `DATABASE_URL`: SQLite/PostgreSQL connection string - `LOG_LEVEL`: Logging level (debug, info, warn, error) - `EARN_DATA_API_BASE_URL`: Earn Data API base URL (default: `https://earn.li.fi`) -- `COMPOSER_API_BASE_URL`: Composer API base URL (default: `https://li.quest`) +- `LI_FI_API_BASE_URL`: LI.FI API base URL (Composer + Analytics, default: `https://li.quest`) - `COMPOSER_API_KEY`: LI.FI Partner Portal API key (required for Composer quote route) - `BEHAVIOR_*`: Thresholds for C-end behavior risk model (velocity, amount spikes, decision drift) +- `JWT_SECRET`: JWT signing secret for admin tokens +- `ADMIN_INIT_USERNAME`: Initial admin username bootstrap +- `ADMIN_INIT_PASSWORD`: Initial admin password bootstrap +- `DEMO_API_KEY`: Fixed API key for demo/integration environments ### Security Features - Rate limiting on public endpoints - Input validation on all endpoints +- JWT authentication for admin routes +- API key authentication for AML/admin protected routes - Helmet security headers - CORS configuration - Parameterized queries (Prisma) diff --git a/backend/.env.example b/backend/.env.example index 60f8338..085c7b3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,9 +5,15 @@ DATABASE_URL="file:./dev.db" LOG_LEVEL=info API_VERSION=v1 EARN_DATA_API_BASE_URL=https://earn.li.fi -COMPOSER_API_BASE_URL=https://li.quest +LI_FI_API_BASE_URL=https://li.quest COMPOSER_API_KEY= +# Authentication +JWT_SECRET=replace-with-a-long-random-secret +ADMIN_INIT_USERNAME=admin +ADMIN_INIT_PASSWORD=change-me +DEMO_API_KEY=bridgeshield-demo-key + # Rate Limiting RATE_LIMIT_WINDOW_MS=60000 RATE_LIMIT_MAX_REQUESTS=100 diff --git a/backend/package-lock.json b/backend/package-lock.json index 466d316..8552545 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "express": "^4.18.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.3", "node-cache": "^5.1.2", "opossum": "^8.0.0", "uuid": "^9.0.1", @@ -23,6 +24,7 @@ "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.10.5", "@types/node-cache": "^4.2.5", "@types/opossum": "^8.1.9", @@ -941,9 +943,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -961,9 +960,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -981,9 +977,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1001,9 +994,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1021,9 +1011,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1041,9 +1028,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1261,6 +1245,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -1275,6 +1270,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", @@ -1916,6 +1918,12 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2314,6 +2322,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3394,6 +3411,49 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3567,9 +3627,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3591,9 +3648,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3615,9 +3669,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3639,9 +3690,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3713,6 +3761,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3720,6 +3804,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -4427,7 +4517,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/backend/package.json b/backend/package.json index a4abd13..b34f4e3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,6 +33,7 @@ "express": "^4.18.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.3", "node-cache": "^5.1.2", "opossum": "^8.0.0", "uuid": "^9.0.1", @@ -41,6 +42,7 @@ "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.10.5", "@types/node-cache": "^4.2.5", "@types/opossum": "^8.1.9", diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db index ef29dfa..e0d3df5 100644 Binary files a/backend/prisma/dev.db and b/backend/prisma/dev.db differ diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9418dfc..43e2286 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -85,4 +85,32 @@ model AuditLog { createdAt DateTime @default(now()) @@map("audit_logs") -} \ No newline at end of file +} + +model AdminUser { + id String @id @default(cuid()) + username String @unique + passwordHash String + role String @default("ADMIN") + isActive Boolean @default(true) + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("admin_users") +} + +model ApiCredential { + id String @id @default(cuid()) + name String + keyHash String @unique + keyPrefix String + scopes String? // JSON string array for SQLite + isActive Boolean @default(true) + expiresAt DateTime? + lastUsedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("api_credentials") +} diff --git a/backend/src/api/middleware/auth.ts b/backend/src/api/middleware/auth.ts new file mode 100644 index 0000000..b3ee200 --- /dev/null +++ b/backend/src/api/middleware/auth.ts @@ -0,0 +1,154 @@ +import type { NextFunction, Request, Response } from 'express'; +import { logger } from './logger'; +import { + ADMIN_API_SCOPE, + type AdminAuthContext, + type ApiKeyAuthContext, + validateApiKey, + verifyAdminAccessToken, +} from '../../services/auth-service'; + +interface RequestAuth { + admin?: AdminAuthContext; + apiKey?: ApiKeyAuthContext; +} + +declare global { + namespace Express { + interface Request { + auth?: RequestAuth; + } + } +} + +const getHeaderValue = (value: string | string[] | undefined): string | null => { + if (Array.isArray(value)) { + return value[0] || null; + } + + if (typeof value === 'string') { + return value; + } + + return null; +}; + +const getBearerToken = (req: Request): string | null => { + const authorization = getHeaderValue(req.headers.authorization); + if (!authorization) { + return null; + } + + const [scheme, token] = authorization.split(' '); + if (scheme?.toLowerCase() !== 'bearer' || !token?.trim()) { + return null; + } + + return token.trim(); +}; + +const getApiKeyHeader = (req: Request): string | null => { + const headerValue = getHeaderValue(req.headers['x-api-key']); + return headerValue?.trim() || null; +}; + +const setAdminAuthFromApiKey = (req: Request, apiKey: ApiKeyAuthContext): void => { + req.auth = { + admin: { + userId: apiKey.credentialId || `api-key:${apiKey.credentialName}`, + username: apiKey.credentialName, + role: 'API_KEY', + authMethod: 'api_key', + }, + apiKey, + }; +}; + +const respondUnauthorized = ( + res: Response, + message: string +): Response => res.status(401).json({ + error: 'Unauthorized', + message, +}); + +export const requireApiKey = (requiredScope?: string) => async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tokenCandidate = getApiKeyHeader(req) || getBearerToken(req); + + if (!tokenCandidate) { + respondUnauthorized(res, 'Valid API key is required'); + return; + } + + const apiKey = await validateApiKey(tokenCandidate, requiredScope); + + if (!apiKey) { + respondUnauthorized(res, 'Valid API key is required'); + return; + } + + req.auth = { + ...req.auth, + apiKey, + }; + next(); + } catch (error) { + logger.error('API key authentication failed unexpectedly', { error }); + res.status(500).json({ + error: 'Internal server error', + message: 'Authentication failed', + }); + } +}; + +export const requireAdminAuth = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const bearerToken = getBearerToken(req); + + if (bearerToken) { + const adminFromJwt = verifyAdminAccessToken(bearerToken); + if (adminFromJwt) { + req.auth = { + ...req.auth, + admin: adminFromJwt, + }; + next(); + return; + } + + const apiKeyFromBearer = await validateApiKey(bearerToken, ADMIN_API_SCOPE); + if (apiKeyFromBearer) { + setAdminAuthFromApiKey(req, apiKeyFromBearer); + next(); + return; + } + } + + const apiKeyHeader = getApiKeyHeader(req); + if (apiKeyHeader) { + const apiKey = await validateApiKey(apiKeyHeader, ADMIN_API_SCOPE); + if (apiKey) { + setAdminAuthFromApiKey(req, apiKey); + next(); + return; + } + } + + respondUnauthorized(res, 'Admin authentication required'); + } catch (error) { + logger.error('Admin authentication failed unexpectedly', { error }); + res.status(500).json({ + error: 'Internal server error', + message: 'Authentication failed', + }); + } +}; diff --git a/backend/src/api/middleware/validator.ts b/backend/src/api/middleware/validator.ts index 37ad52c..45f47cc 100644 --- a/backend/src/api/middleware/validator.ts +++ b/backend/src/api/middleware/validator.ts @@ -254,6 +254,62 @@ export const earnPortfolioValidator = (req: Request, res: Response, next: NextFu next(); }; +export const validateAnalyticsTransfersInput = (req: Request): ValidationErrorItem[] => { + const errors: ValidationErrorItem[] = []; + + const wallet = getSingleQueryValue(req.query.wallet); + const fromChainStr = getSingleQueryValue(req.query.fromChain); + const toChainStr = getSingleQueryValue(req.query.toChain); + const fromTime = getSingleQueryValue(req.query.fromTime); + const toTime = getSingleQueryValue(req.query.toTime); + const limitStr = getSingleQueryValue(req.query.limit); + + if (!wallet) { + errors.push({ field: 'wallet', message: 'Wallet address is required' }); + } else if (!validateAddress(wallet)) { + errors.push({ field: 'wallet', message: 'Invalid wallet address format' }); + } + + if (fromChainStr) { + const fromChain = parseInt(fromChainStr, 10); + if (!validateChainId(fromChain)) { + errors.push({ field: 'fromChain', message: 'fromChain must be a positive integer' }); + } + } + + if (toChainStr) { + const toChain = parseInt(toChainStr, 10); + if (!validateChainId(toChain)) { + errors.push({ field: 'toChain', message: 'toChain must be a positive integer' }); + } + } + + if (fromTime) { + const fromTimestamp = Date.parse(fromTime); + if (isNaN(fromTimestamp)) { + errors.push({ field: 'fromTime', message: 'fromTime must be a valid ISO 8601 timestamp' }); + } + } + + if (toTime) { + const toTimestamp = Date.parse(toTime); + if (isNaN(toTimestamp)) { + errors.push({ field: 'toTime', message: 'toTime must be a valid ISO 8601 timestamp' }); + } + } + + if (limitStr) { + const limit = parseInt(limitStr, 10); + if (isNaN(limit) || limit <= 0) { + errors.push({ field: 'limit', message: 'limit must be a positive integer' }); + } else if (limit > 100) { + errors.push({ field: 'limit', message: 'limit cannot exceed 100' }); + } + } + + return errors; +}; + export const composerQuoteValidator = (req: Request, res: Response, next: NextFunction): void => { const errors = validateComposerQuoteInput(req); @@ -269,6 +325,21 @@ export const composerQuoteValidator = (req: Request, res: Response, next: NextFu next(); }; +export const analyticsTransfersValidator = (req: Request, res: Response, next: NextFunction): void => { + const errors = validateAnalyticsTransfersInput(req); + + if (errors.length > 0) { + logger.warn('Analytics transfers validation failed', { errors, query: req.query }); + res.status(400).json({ + error: 'Validation failed', + errors + }); + return; + } + + next(); +}; + export const handleValidationError = ( error: Error, req: Request, diff --git a/backend/src/api/routes/admin-auth.ts b/backend/src/api/routes/admin-auth.ts new file mode 100644 index 0000000..217473a --- /dev/null +++ b/backend/src/api/routes/admin-auth.ts @@ -0,0 +1,78 @@ +import type { Request, Response } from 'express'; +import { Router } from 'express'; +import { requireAdminAuth } from '../middleware/auth'; +import { logger } from '../middleware/logger'; +import { + authenticateAdminCredentials, + createAdminAccessToken, + ensureInitialAdminUser, +} from '../../services/auth-service'; + +const router = Router(); + +router.post('/login', async (req: Request, res: Response) => { + try { + const { username, password } = req.body ?? {}; + + if ( + typeof username !== 'string' || + typeof password !== 'string' || + username.trim().length === 0 || + password.length === 0 + ) { + res.status(400).json({ + error: 'Validation failed', + message: 'Username and password are required', + }); + return; + } + + await ensureInitialAdminUser(); + + const admin = await authenticateAdminCredentials(username, password); + if (!admin) { + res.status(401).json({ + error: 'Unauthorized', + message: 'Invalid credentials', + }); + return; + } + + const token = createAdminAccessToken(admin); + res.json({ + accessToken: token.accessToken, + tokenType: 'Bearer', + expiresIn: token.expiresIn, + user: { + id: admin.id, + username: admin.username, + role: admin.role, + }, + }); + } catch (error) { + logger.error('Admin login failed', { error }); + res.status(500).json({ + error: 'Internal server error', + message: 'Login failed', + }); + } +}); + +router.get('/me', requireAdminAuth, (req: Request, res: Response) => { + if (!req.auth?.admin) { + res.status(401).json({ + error: 'Unauthorized', + message: 'Admin authentication required', + }); + return; + } + + res.json({ + id: req.auth.admin.userId, + username: req.auth.admin.username, + role: req.auth.admin.role, + authMethod: req.auth.admin.authMethod, + }); +}); + +export default router; diff --git a/backend/src/api/routes/admin.ts b/backend/src/api/routes/admin.ts index 15a70e4..af61699 100644 --- a/backend/src/api/routes/admin.ts +++ b/backend/src/api/routes/admin.ts @@ -302,6 +302,7 @@ router.post('/appeal/:id/approve', async (req: Request, res: Response) => { try { const { id } = req.params; const now = new Date(); + const reviewer = req.auth?.admin?.username || 'admin'; const appeal = await prismaService.getClient().$transaction(async (tx) => { const existingAppeal = await tx.appeal.findUnique({ where: { id } @@ -349,7 +350,7 @@ router.post('/appeal/:id/approve', async (req: Request, res: Response) => { data: { status: 'APPROVED', reviewedAt: now, - reviewer: 'admin', + reviewer, decision: 'APPROVED' } }); @@ -398,6 +399,7 @@ router.post('/appeal/:id/reject', async (req: Request, res: Response) => { const { id } = req.params; const { notes } = req.body; const now = new Date(); + const reviewer = req.auth?.admin?.username || 'admin'; const result = await prismaService.getClient().$transaction(async (tx) => { const existingAppeal = await tx.appeal.findUnique({ @@ -428,7 +430,7 @@ router.post('/appeal/:id/reject', async (req: Request, res: Response) => { data: { status: 'REJECTED', reviewedAt: now, - reviewer: 'admin', + reviewer, decision: 'REJECTED', notes: notes || null } diff --git a/backend/src/api/routes/analytics.ts b/backend/src/api/routes/analytics.ts new file mode 100644 index 0000000..19ca8bb --- /dev/null +++ b/backend/src/api/routes/analytics.ts @@ -0,0 +1,155 @@ +import { Router, Request, Response } from 'express'; +import { AnalyticsService, TransferFilters } from '../../services/analytics-service'; +import { checkRateLimiter } from '../middleware/rate-limiter'; +import { analyticsTransfersValidator } from '../middleware/validator'; +import { logger } from '../middleware/logger'; + +const router = Router(); +const analyticsService = new AnalyticsService(); + +const getSingleQueryValue = (value: unknown): string => { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'string') { + return value[0]; + } + + return ''; +}; + +const buildQueryString = (query: Request['query']): string => { + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + if (typeof value === 'string') { + params.append(key, value); + continue; + } + + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === 'string') { + params.append(key, item); + } + } + } + } + + return params.toString(); +}; + +const parseUpstreamBody = async (upstreamResponse: globalThis.Response): Promise => { + const contentType = upstreamResponse.headers.get('content-type') || ''; + + if (contentType.includes('application/json')) { + return upstreamResponse.json(); + } + + const textBody = await upstreamResponse.text(); + return { raw: textBody }; +}; + +const withProxyMeta = (payload: unknown, endpoint: string, requestedUrl: string, fallbackUsed?: boolean): Record => { + if (typeof payload === 'object' && payload !== null && !Array.isArray(payload)) { + return { + ...(payload as Record), + _bridgeShield: { + proxied: true, + source: 'li.quest', + endpoint, + requestedUrl, + fallbackUsed: fallbackUsed || false + } + }; + } + + return { + data: payload, + _bridgeShield: { + proxied: true, + source: 'li.quest', + endpoint, + requestedUrl, + fallbackUsed: fallbackUsed || false + } + }; +}; + +router.get('/transfers', checkRateLimiter, analyticsTransfersValidator, async (req: Request, res: Response) => { + try { + const wallet = getSingleQueryValue(req.query.wallet); + const status = getSingleQueryValue(req.query.status); + const fromChainStr = getSingleQueryValue(req.query.fromChain); + const toChainStr = getSingleQueryValue(req.query.toChain); + const fromTime = getSingleQueryValue(req.query.fromTime); + const toTime = getSingleQueryValue(req.query.toTime); + const cursor = getSingleQueryValue(req.query.cursor); + const limitStr = getSingleQueryValue(req.query.limit); + + const filters: TransferFilters = {}; + + if (status) filters.status = status; + if (fromChainStr) filters.fromChain = parseInt(fromChainStr, 10); + if (toChainStr) filters.toChain = parseInt(toChainStr, 10); + if (fromTime) filters.fromTimestamp = fromTime; + if (toTime) filters.toTimestamp = toTime; + if (cursor) filters.cursor = cursor; + if (limitStr) { + const limit = parseInt(limitStr, 10); + if (limit > 0 && limit <= 100) { + filters.limit = limit; + } + } + + const result = await analyticsService.fetchTransfers(wallet, filters); + + const rateLimitInfo = analyticsService.getRateLimitInfo(); + if (rateLimitInfo) { + res.setHeader('X-RateLimit-Limit', rateLimitInfo.limit.toString()); + res.setHeader('X-RateLimit-Remaining', rateLimitInfo.remaining.toString()); + res.setHeader('X-RateLimit-Reset', rateLimitInfo.reset.toString()); + } + + const queryString = buildQueryString(req.query); + const endpointPath = '/v2/analytics/transfers'; + const requestedUrl = `https://li.quest${endpointPath}${queryString ? `?${queryString}` : ''}`; + + res.json(withProxyMeta(result, endpointPath, requestedUrl, result.fallbackUsed)); + } catch (error) { + logger.error('Analytics transfers request failed', { error, query: req.query }); + + if (error instanceof Error) { + if (error.message.includes('rate limit')) { + res.status(429).json({ + error: 'Rate limit exceeded', + message: 'LI.FI Analytics API rate limit exceeded. Please try again later.', + upstreamError: error.message + }); + return; + } else if (error.message.includes('validation error')) { + res.status(400).json({ + error: 'Bad request', + message: 'Invalid request parameters', + upstreamError: error.message + }); + return; + } else if (error.message.includes('server error')) { + res.status(502).json({ + error: 'Bad gateway', + message: 'LI.FI Analytics API server error', + upstreamError: error.message + }); + return; + } + } + + res.status(502).json({ + error: 'Bad gateway', + message: 'Failed to reach analytics service' + }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/src/api/routes/composer.ts b/backend/src/api/routes/composer.ts index dfccab7..b586fad 100644 --- a/backend/src/api/routes/composer.ts +++ b/backend/src/api/routes/composer.ts @@ -8,7 +8,7 @@ import { logger } from '../middleware/logger'; const router = Router(); -const COMPOSER_API_BASE_URL = process.env.COMPOSER_API_BASE_URL || 'https://li.quest'; +const LI_FI_API_BASE_URL = process.env.LI_FI_API_BASE_URL || process.env.COMPOSER_API_BASE_URL || 'https://li.quest'; const COMPOSER_API_KEY = process.env.COMPOSER_API_KEY || ''; const riskDataLoader = RiskDataLoader.getInstance(); @@ -179,7 +179,7 @@ router.get('/quote', checkRateLimiter, composerQuoteValidator, async (req: Reque const queryString = buildComposerQueryString(req.query); const quotePath = '/v1/quote'; - const upstreamUrl = `${COMPOSER_API_BASE_URL}${quotePath}${queryString ? `?${queryString}` : ''}`; + const upstreamUrl = `${LI_FI_API_BASE_URL}${quotePath}${queryString ? `?${queryString}` : ''}`; const upstreamResponse = await fetch(upstreamUrl, { method: 'GET', @@ -213,7 +213,7 @@ router.get('/quote', checkRateLimiter, composerQuoteValidator, async (req: Reque quote: upstreamBody, source: { method: 'GET', - composerApi: COMPOSER_API_BASE_URL, + lifiApi: LI_FI_API_BASE_URL, quotePath } }); diff --git a/backend/src/app.ts b/backend/src/app.ts index 6c64de1..69dc16e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,6 +9,8 @@ import { CacheService } from './services/cache-service'; import { logger, requestLogger } from './api/middleware/logger'; import { apiRateLimiter } from './api/middleware/rate-limiter'; import { handleValidationError } from './api/middleware/validator'; +import { requireAdminAuth, requireApiKey } from './api/middleware/auth'; +import { AML_API_SCOPE, ensureInitialAdminUser } from './services/auth-service'; import healthRouter from './api/routes/health'; import checkRouter from './api/routes/check'; @@ -18,6 +20,8 @@ import adminRouter from './api/routes/admin'; import earnRouter from './api/routes/earn'; import composerRouter from './api/routes/composer'; import behaviorRouter from './api/routes/behavior'; +import analyticsRouter from './api/routes/analytics'; +import adminAuthRouter from './api/routes/admin-auth'; const app = express(); const PORT = process.env.PORT || 3000; @@ -37,13 +41,15 @@ app.use(requestLogger); app.use(apiRateLimiter); app.use(`/api/${API_VERSION}/health`, healthRouter); -app.use(`/api/${API_VERSION}/aml/check`, checkRouter); -app.use(`/api/${API_VERSION}/aml/whitelist`, whitelistRouter); -app.use(`/api/${API_VERSION}/aml/appeal`, appealRouter); -app.use(`/api/${API_VERSION}/admin`, adminRouter); +app.use(`/api/${API_VERSION}/aml/check`, requireApiKey(AML_API_SCOPE), checkRouter); +app.use(`/api/${API_VERSION}/aml/whitelist`, requireApiKey(AML_API_SCOPE), whitelistRouter); +app.use(`/api/${API_VERSION}/aml/appeal`, requireApiKey(AML_API_SCOPE), appealRouter); +app.use(`/api/${API_VERSION}/admin/auth`, adminAuthRouter); +app.use(`/api/${API_VERSION}/admin`, requireAdminAuth, adminRouter); app.use(`/api/${API_VERSION}/earn`, earnRouter); app.use(`/api/${API_VERSION}/composer`, composerRouter); app.use(`/api/${API_VERSION}/behavior`, behaviorRouter); +app.use(`/api/${API_VERSION}/analytics`, analyticsRouter); app.get('/', (req, res) => { res.json({ @@ -55,11 +61,13 @@ app.get('/', (req, res) => { check: `/api/${API_VERSION}/aml/check`, whitelist: `/api/${API_VERSION}/aml/whitelist`, appeal: `/api/${API_VERSION}/aml/appeal`, + adminLogin: `/api/${API_VERSION}/admin/auth/login`, earnVaults: `/api/${API_VERSION}/earn/vaults`, earnVaultDetail: `/api/${API_VERSION}/earn/vault/:network/:address`, earnPortfolio: `/api/${API_VERSION}/earn/portfolio/:wallet`, composerQuote: `/api/${API_VERSION}/composer/quote`, - behaviorProfile: `/api/${API_VERSION}/behavior/profile/:wallet` + behaviorProfile: `/api/${API_VERSION}/behavior/profile/:wallet`, + analyticsTransfers: `/api/${API_VERSION}/analytics/transfers` }, documentation: 'https://docs.bridgeshield.io' }); @@ -95,6 +103,9 @@ async function initializeServices() { await prismaService.connect(); logger.info('Database connected'); + + await ensureInitialAdminUser(); + logger.info('Admin bootstrap checked'); await riskDataLoader.initialize(); logger.info('Risk data loaded'); diff --git a/backend/src/services/analytics-service.ts b/backend/src/services/analytics-service.ts new file mode 100644 index 0000000..11b1feb --- /dev/null +++ b/backend/src/services/analytics-service.ts @@ -0,0 +1,295 @@ +import { PrismaService } from '../db/prisma-client'; +import NodeCache from 'node-cache'; +import { logger } from '../api/middleware/logger'; +import { validateAddress } from '../api/middleware/validator'; + +export interface Transfer { + id: string; + fromAddress: string; + toAddress: string; + fromChain: number; + toChain: number; + amount: string; + amountUsd?: number; + status: string; + timestamp: string; + txHash?: string; + feeAmount?: string; + feeToken?: string; + riskLevel?: 'LOW' | 'MEDIUM' | 'HIGH'; + riskScore?: number; +} + +export interface TransferFilters { + status?: string; + fromChain?: number; + toChain?: number; + fromTimestamp?: string; + toTimestamp?: string; + cursor?: string; + limit?: number; +} + +export interface PaginatedTransfers { + transfers: Transfer[]; + hasNext: boolean; + hasPrevious: boolean; + next: string | null; + previous: string | null; + fallbackUsed?: boolean; +} + +export interface RateLimitInfo { + limit: number; + remaining: number; + reset: number; +} + +export class AnalyticsService { + private prismaService: PrismaService; + private cache: NodeCache; + private baseUrl: string; + private rateLimitInfo: RateLimitInfo | null = null; + + constructor( + prismaService: PrismaService = PrismaService.getInstance() + ) { + this.prismaService = prismaService; + this.cache = new NodeCache({ stdTTL: 900, checkperiod: 600 }); + this.baseUrl = process.env.LI_FI_API_BASE_URL || 'https://li.quest'; + } + + public async fetchTransfers( + address: string, + filters?: TransferFilters + ): Promise { + const normalizedAddress = address.toLowerCase(); + const cacheKey = this.buildCacheKey(normalizedAddress, filters); + + const cachedResult = this.cache.get(cacheKey); + if (cachedResult) { + logger.debug('Cache hit for analytics transfers', { address, filters }); + return cachedResult; + } + + try { + const url = this.buildUrl(normalizedAddress, filters); + logger.debug('Fetching transfers from LI.FI API', { url }); + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + + this.updateRateLimitInfo(response); + + if (!response.ok) { + const errorText = await response.text(); + logger.warn('LI.FI API request failed', { + status: response.status, + statusText: response.statusText, + error: errorText + }); + + if (response.status === 429) { + throw new Error(`LI.FI Analytics API rate limit exceeded: ${response.status} ${response.statusText}`); + } else if (response.status === 400) { + throw new Error(`LI.FI Analytics API validation error: ${response.status} ${response.statusText}`); + } else if (response.status >= 500) { + throw new Error(`LI.FI Analytics API server error: ${response.status} ${response.statusText}`); + } else { + throw new Error(`LI.FI Analytics API error: ${response.status} ${response.statusText}`); + } + } + + const data = await response.json(); + const transformed = this.transformResponse(data); + + this.cache.set(cacheKey, transformed); + + return transformed; + } catch (error) { + if (error instanceof Error && + !error.message.includes('rate limit') && + !error.message.includes('validation error') && + !error.message.includes('server error')) { + + logger.warn('LI.FI API failed, falling back to local database', { error }); + + try { + const localTransfers = await this.queryLocalTransfers(normalizedAddress, filters); + const transformedLocal = this.transformLocalTransfers(localTransfers); + + transformedLocal.fallbackUsed = true; + + this.cache.set(cacheKey, transformedLocal, 300); + + return transformedLocal; + } catch (fallbackError) { + logger.error('Both LI.FI API and local fallback failed', { fallbackError }); + throw new Error(`Failed to fetch transfers: ${error instanceof Error ? error.message : String(error)}`); + } + } + + throw error; + } + } + + public transformResponse(liFiResponse: any): PaginatedTransfers { + const transfers: Transfer[] = (liFiResponse.transfers || []).map((transfer: any) => ({ + id: transfer.id, + fromAddress: transfer.fromAddress, + toAddress: transfer.toAddress, + fromChain: transfer.fromChain, + toChain: transfer.toChain, + amount: transfer.amount, + amountUsd: transfer.amountUsd, + status: transfer.status, + timestamp: transfer.timestamp, + txHash: transfer.txHash, + feeAmount: transfer.feeAmount, + feeToken: transfer.feeToken, + riskLevel: undefined, + riskScore: undefined + })); + + return { + transfers, + hasNext: liFiResponse.hasNext || false, + hasPrevious: liFiResponse.hasPrevious || false, + next: liFiResponse.next || null, + previous: liFiResponse.previous || null + }; + } + + public async queryLocalTransfers(address: string, filters?: TransferFilters): Promise { + const normalizedAddress = address.toLowerCase(); + + const whereClause: any = { + address: normalizedAddress + }; + + if (filters?.fromTimestamp || filters?.toTimestamp) { + whereClause.createdAt = {}; + if (filters.fromTimestamp) { + whereClause.createdAt.gte = new Date(filters.fromTimestamp); + } + if (filters.toTimestamp) { + whereClause.createdAt.lte = new Date(filters.toTimestamp); + } + } + + if (filters?.status) { + whereClause.decision = filters.status.toUpperCase(); + } + + const logs = await this.prismaService.getClient().checkLog.findMany({ + where: whereClause, + orderBy: { createdAt: 'desc' }, + take: filters?.limit || 50 + }); + + return logs.map(log => { + let requestData: any = {}; + try { + if (log.requestData) { + requestData = JSON.parse(log.requestData); + } + } catch (e) { + } + + return { + id: log.checkId, + fromAddress: log.address, + toAddress: log.address, + fromChain: log.chainId, + toChain: log.chainId, + amount: requestData.amount || '0', + status: log.decision, + timestamp: log.createdAt.toISOString(), + riskLevel: log.riskLevel as 'LOW' | 'MEDIUM' | 'HIGH', + riskScore: log.riskScore + }; + }); + } + + public getRateLimitInfo(): RateLimitInfo | null { + return this.rateLimitInfo; + } + + private buildCacheKey(address: string, filters?: TransferFilters): string { + const filterString = filters ? JSON.stringify(filters) : ''; + return `analytics:transfers:${address}:${filterString}`; + } + + private buildUrl(address: string, filters?: TransferFilters): string { + const params = new URLSearchParams(); + params.append('wallet', address); + + if (filters?.status) { + params.append('status', filters.status); + } + if (filters?.fromChain) { + params.append('fromChain', filters.fromChain.toString()); + } + if (filters?.toChain) { + params.append('toChain', filters.toChain.toString()); + } + if (filters?.fromTimestamp) { + params.append('fromTimestamp', filters.fromTimestamp); + } + if (filters?.toTimestamp) { + params.append('toTimestamp', filters.toTimestamp); + } + if (filters?.cursor) { + params.append('cursor', filters.cursor); + } + if (filters?.limit) { + params.append('limit', filters.limit.toString()); + } + + const queryString = params.toString(); + return `${this.baseUrl}/v2/analytics/transfers${queryString ? `?${queryString}` : ''}`; + } + + private updateRateLimitInfo(response: Response): void { + const limit = response.headers.get('X-RateLimit-Limit'); + const remaining = response.headers.get('X-RateLimit-Remaining'); + const reset = response.headers.get('X-RateLimit-Reset'); + + if (limit && remaining && reset) { + this.rateLimitInfo = { + limit: parseInt(limit, 10), + remaining: parseInt(remaining, 10), + reset: parseInt(reset, 10) + }; + } + } + + private transformLocalTransfers(localTransfers: any[]): PaginatedTransfers { + const transfers: Transfer[] = localTransfers.map(transfer => ({ + id: transfer.id, + fromAddress: transfer.fromAddress, + toAddress: transfer.toAddress, + fromChain: transfer.fromChain, + toChain: transfer.toChain, + amount: transfer.amount, + status: transfer.status, + timestamp: transfer.timestamp, + riskLevel: transfer.riskLevel, + riskScore: transfer.riskScore + })); + + return { + transfers, + hasNext: false, + hasPrevious: false, + next: null, + previous: null, + fallbackUsed: true + }; + } +} \ No newline at end of file diff --git a/backend/src/services/auth-service.ts b/backend/src/services/auth-service.ts new file mode 100644 index 0000000..241d039 --- /dev/null +++ b/backend/src/services/auth-service.ts @@ -0,0 +1,317 @@ +import crypto from 'crypto'; +import type { AdminUser } from '@prisma/client'; +import jwt from 'jsonwebtoken'; +import { PrismaService } from '../db/prisma-client'; +import { logger } from '../api/middleware/logger'; + +const DEFAULT_JWT_EXPIRES_IN = '12h'; +const DEFAULT_JWT_EXPIRES_IN_SECONDS = 60 * 60 * 12; +const JWT_ISSUER = 'bridgeshield'; +const DEV_JWT_SECRET = 'bridgeshield-dev-jwt-secret-change-me'; +const PASSWORD_HASH_PREFIX = 'scrypt'; +const PASSWORD_HASH_KEY_LENGTH = 64; + +export const ADMIN_API_SCOPE = 'admin'; +export const AML_API_SCOPE = 'aml'; + +export interface AdminAuthContext { + userId: string; + username: string; + role: string; + authMethod: 'jwt' | 'api_key'; +} + +export interface ApiKeyAuthContext { + credentialId: string | null; + credentialName: string; + scopes: string[]; + source: 'demo' | 'database'; +} + +interface AdminJwtPayload extends jwt.JwtPayload { + sub: string; + username: string; + role: string; + type: 'admin'; +} + +const prismaService = PrismaService.getInstance(); + +let hasWarnedAboutJwtFallback = false; +let initialAdminBootstrapped = false; +let initialAdminBootstrapPromise: Promise | null = null; + +const normalizeUsername = (username: string): string => username.trim().toLowerCase(); + +const safeCompare = (left: string, right: string): boolean => { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(leftBuffer, rightBuffer); +}; + +const getJwtSecret = (): string => { + const configuredSecret = process.env.JWT_SECRET?.trim(); + + if (configuredSecret) { + return configuredSecret; + } + + if (process.env.NODE_ENV === 'production') { + throw new Error('JWT_SECRET must be configured in production'); + } + + if (!hasWarnedAboutJwtFallback) { + hasWarnedAboutJwtFallback = true; + logger.warn('JWT_SECRET is not set, using insecure development fallback secret'); + } + + return DEV_JWT_SECRET; +}; + +const parseScopes = (rawScopes: string | null): string[] => { + if (!rawScopes) { + return ['*']; + } + + try { + const parsed = JSON.parse(rawScopes); + if (Array.isArray(parsed)) { + return parsed + .filter((scope): scope is string => typeof scope === 'string') + .map((scope) => scope.trim()) + .filter(Boolean); + } + } catch { + // Ignore and fallback to comma-separated parsing. + } + + return rawScopes + .split(',') + .map((scope) => scope.trim()) + .filter(Boolean); +}; + +const hasRequiredScope = (scopes: string[], requiredScope?: string): boolean => { + if (!requiredScope) { + return true; + } + + return scopes.includes('*') || scopes.includes(requiredScope); +}; + +export const hashApiKey = (apiKey: string): string => + crypto.createHash('sha256').update(apiKey).digest('hex'); + +export const getApiKeyPrefix = (apiKey: string): string => apiKey.slice(0, 8); + +export const hashPassword = (password: string): string => { + if (!password) { + throw new Error('Password must not be empty'); + } + + const salt = crypto.randomBytes(16).toString('hex'); + const hash = crypto.scryptSync(password, salt, PASSWORD_HASH_KEY_LENGTH).toString('hex'); + return `${PASSWORD_HASH_PREFIX}:${salt}:${hash}`; +}; + +export const verifyPassword = (password: string, passwordHash: string): boolean => { + const [prefix, salt, expectedHash] = passwordHash.split(':'); + + if (prefix !== PASSWORD_HASH_PREFIX || !salt || !expectedHash) { + logger.warn('Unsupported password hash format for admin user'); + return false; + } + + const actualHash = crypto.scryptSync(password, salt, PASSWORD_HASH_KEY_LENGTH).toString('hex'); + return safeCompare(actualHash, expectedHash); +}; + +export const createAdminAccessToken = (admin: Pick): { + accessToken: string; + expiresIn: number; +} => { + const payload: AdminJwtPayload = { + sub: admin.id, + username: admin.username, + role: admin.role, + type: 'admin', + }; + + const accessToken = jwt.sign(payload, getJwtSecret(), { + algorithm: 'HS256', + expiresIn: DEFAULT_JWT_EXPIRES_IN, + issuer: JWT_ISSUER, + }); + + return { + accessToken, + expiresIn: DEFAULT_JWT_EXPIRES_IN_SECONDS, + }; +}; + +export const verifyAdminAccessToken = (token: string): AdminAuthContext | null => { + try { + const payload = jwt.verify(token, getJwtSecret(), { + algorithms: ['HS256'], + issuer: JWT_ISSUER, + }) as AdminJwtPayload; + + if ( + payload.type !== 'admin' || + typeof payload.sub !== 'string' || + typeof payload.username !== 'string' || + typeof payload.role !== 'string' + ) { + return null; + } + + return { + userId: payload.sub, + username: payload.username, + role: payload.role, + authMethod: 'jwt', + }; + } catch { + return null; + } +}; + +export const validateApiKey = async ( + apiKey: string, + requiredScope?: string +): Promise => { + const candidate = apiKey.trim(); + if (!candidate) { + return null; + } + + const demoApiKey = process.env.DEMO_API_KEY?.trim(); + if (demoApiKey && safeCompare(candidate, demoApiKey)) { + return { + credentialId: null, + credentialName: 'demo-api-key', + scopes: ['*'], + source: 'demo', + }; + } + + const credential = await prismaService.getClient().apiCredential.findUnique({ + where: { keyHash: hashApiKey(candidate) }, + }); + + if (!credential || !credential.isActive) { + return null; + } + + if (credential.expiresAt && credential.expiresAt <= new Date()) { + return null; + } + + const scopes = parseScopes(credential.scopes); + if (!hasRequiredScope(scopes, requiredScope)) { + return null; + } + + await prismaService.getClient().apiCredential.update({ + where: { id: credential.id }, + data: { lastUsedAt: new Date() }, + }); + + return { + credentialId: credential.id, + credentialName: credential.name, + scopes, + source: 'database', + }; +}; + +export const authenticateAdminCredentials = async ( + username: string, + password: string +): Promise | null> => { + const normalizedUsername = normalizeUsername(username); + + if (!normalizedUsername || !password) { + return null; + } + + const admin = await prismaService.getClient().adminUser.findUnique({ + where: { username: normalizedUsername }, + }); + + if (!admin || !admin.isActive) { + return null; + } + + if (!verifyPassword(password, admin.passwordHash)) { + return null; + } + + const updatedAdmin = await prismaService.getClient().adminUser.update({ + where: { id: admin.id }, + data: { lastLoginAt: new Date() }, + }); + + return { + id: updatedAdmin.id, + username: updatedAdmin.username, + role: updatedAdmin.role, + }; +}; + +const bootstrapInitialAdminUser = async (): Promise => { + const username = process.env.ADMIN_INIT_USERNAME?.trim(); + const password = process.env.ADMIN_INIT_PASSWORD; + + if (!username || !password) { + logger.warn('Initial admin bootstrap skipped: ADMIN_INIT_USERNAME or ADMIN_INIT_PASSWORD missing'); + initialAdminBootstrapped = true; + return; + } + + const normalizedUsername = normalizeUsername(username); + const existingAdmin = await prismaService.getClient().adminUser.findUnique({ + where: { username: normalizedUsername }, + }); + + if (existingAdmin) { + initialAdminBootstrapped = true; + return; + } + + await prismaService.getClient().adminUser.create({ + data: { + username: normalizedUsername, + passwordHash: hashPassword(password), + role: 'ADMIN', + isActive: true, + }, + }); + + initialAdminBootstrapped = true; + logger.info('Initial admin user created', { username: normalizedUsername }); +}; + +export const ensureInitialAdminUser = async (): Promise => { + if (initialAdminBootstrapped) { + return; + } + + if (!initialAdminBootstrapPromise) { + initialAdminBootstrapPromise = bootstrapInitialAdminUser() + .catch((error) => { + initialAdminBootstrapped = false; + throw error; + }) + .finally(() => { + initialAdminBootstrapPromise = null; + }); + } + + await initialAdminBootstrapPromise; +}; diff --git a/backend/src/services/behavior-analyzer.ts b/backend/src/services/behavior-analyzer.ts index 7d3d319..aad4c8b 100644 --- a/backend/src/services/behavior-analyzer.ts +++ b/backend/src/services/behavior-analyzer.ts @@ -1,6 +1,7 @@ import { PrismaService } from '../db/prisma-client'; import { logger } from '../api/middleware/logger'; import { ValidationError, validateAddress } from '../api/middleware/validator'; +import { Transfer } from './analytics-service'; export interface BehaviorAnalysisInput { address: string; @@ -30,13 +31,16 @@ export interface BehaviorProfile { maxAmount7d?: number; amountSpikeRatio?: number; recentRiskDecisionRatio: number; + lifiHistoryFallback?: boolean; }; adjustments: { velocity: number; chainNovelty: number; amount: number; decisionDrift: number; + lifiSignals: number; }; + lifiSignals?: string[]; recommendation: string; asOf: string; } @@ -125,7 +129,8 @@ export class BehaviorAnalyzerService { public calculateProfile( input: BehaviorAnalysisInput, history: BehaviorHistorySample[], - now: Date = new Date() + now: Date = new Date(), + lifiHistory?: Transfer[] ): BehaviorProfile { const since24h = new Date(now.getTime() - 24 * 60 * 60 * 1000); const checks24h = history.filter((item) => item.createdAt >= since24h).length; @@ -150,13 +155,72 @@ export class BehaviorAnalyzerService { const recentRiskDecisionRatio = recentWindow.length > 0 ? riskyDecisions / recentWindow.length : 0; const signals: string[] = []; + const lifiSignals: string[] = []; const adjustments = { velocity: 0, chainNovelty: 0, amount: 0, - decisionDrift: 0 + decisionDrift: 0, + lifiSignals: 0 }; + let lifiHistoryFallback = false; + + if (lifiHistory && lifiHistory.length > 0) { + const lifiAmounts = lifiHistory + .map(t => { + try { + return Number(t.amount) / 1e18; + } catch { + return 0; + } + }) + .filter(v => v > 0); + const lifiAvgAmount = lifiAmounts.length > 0 ? lifiAmounts.reduce((a, b) => a + b, 0) / lifiAmounts.length : 0; + const lifiUniqueChains = new Set(lifiHistory.map(t => t.fromChain)); + const lifiHighRiskInteractions = lifiHistory.filter(t => { + const toLower = t.toAddress?.toLowerCase(); + return toLower === '0x098b716b8aaf21512996dc57eb0615e2383e2f96' || + toLower === '0x0000000000000000000000000000000000000000'; + }); + + if (lifiHighRiskInteractions.length > 0) { + adjustments.lifiSignals += 25; + lifiSignals.push(`LI.FI history: ${lifiHighRiskInteractions.length} interaction(s) with high-risk address`); + } + + if (lifiUniqueChains.size >= 8) { + adjustments.lifiSignals += 8; + lifiSignals.push(`LI.FI: High chain diversity across ${lifiUniqueChains.size} chains`); + } + + if (currentAmount && lifiAvgAmount > 0) { + const lifiSpikeRatio = currentAmount / lifiAvgAmount; + if (lifiSpikeRatio >= 10) { + adjustments.lifiSignals += 10; + lifiSignals.push(`LI.FI: Amount spike ${lifiSpikeRatio.toFixed(1)}x vs LI.FI historical average`); + } + } + + if (lifiHistory.length === 1 && currentAmount && currentAmount >= 100000) { + adjustments.lifiSignals += 15; + lifiSignals.push('LI.FI: First LI.FI transaction is high-value'); + } + + if (lifiUniqueChains.size >= 5 && lifiAmounts.length >= 5) { + const allChains = lifiHistory.flatMap(t => [t.fromChain, t.toChain]); + const uniqueAllChains = new Set(allChains).size; + if (uniqueAllChains >= 5 && lifiAmounts.length >= 5) { + adjustments.lifiSignals += 12; + lifiSignals.push('LI.FI: Cross-chain activity across multiple chains detected'); + } + } + } else if (lifiHistory !== undefined) { + lifiHistoryFallback = true; + } + + signals.push(...lifiSignals); + if (checks24h >= this.maxChecks24h) { adjustments.velocity += 22; signals.push(`High activity velocity: ${checks24h} checks in 24h`); @@ -200,11 +264,12 @@ export class BehaviorAnalyzerService { signals.push(`Recent risk drift: ${(recentRiskDecisionRatio * 100).toFixed(0)}% non-ALLOW decisions`); } - const rawScore = adjustments.velocity + adjustments.chainNovelty + adjustments.amount + adjustments.decisionDrift; + const rawScore = adjustments.velocity + adjustments.chainNovelty + adjustments.amount + adjustments.decisionDrift + adjustments.lifiSignals; const score = Math.min(rawScore, 100); const confidence: 'LOW' | 'MEDIUM' | 'HIGH' = - checks7d >= 20 ? 'HIGH' : checks7d >= 6 ? 'MEDIUM' : 'LOW'; + checks7d >= 20 || (lifiHistory && lifiHistory.length > 0) ? 'HIGH' : + checks7d >= 6 ? 'MEDIUM' : 'LOW'; if (confidence === 'LOW' && signals.length > 0) { signals.push('Low confidence: limited historical data'); @@ -227,9 +292,11 @@ export class BehaviorAnalyzerService { avgAmount7d, maxAmount7d, amountSpikeRatio, - recentRiskDecisionRatio + recentRiskDecisionRatio, + lifiHistoryFallback }, adjustments, + lifiSignals, recommendation, asOf: now.toISOString() }; diff --git a/backend/tests/integration/analytics.test.ts b/backend/tests/integration/analytics.test.ts new file mode 100644 index 0000000..4692b48 --- /dev/null +++ b/backend/tests/integration/analytics.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import type { Express } from 'express'; +import { PrismaService } from '../../src/db/prisma-client'; + +let app: Express; +let uniqueCounter = 0; +let fetchSpy: ReturnType; + +const createUniqueAddress = () => { + uniqueCounter += 1; + return `0x${(Date.now() + uniqueCounter).toString(16).padStart(40, '0')}`.slice(0, 42); +}; + +const createMockResponse = (data: unknown) => ({ + ok: true, + status: 200, + json: async () => data, + headers: new Map() +}); + +const createErrorResponse = (status: number, statusText: string) => ({ + ok: false, + status, + statusText, + json: async () => ({ error: `${status} error` }), + text: async () => `${status} ${statusText}`, + headers: new Map() +}); + +beforeAll(async () => { + const prisma = PrismaService.getInstance(); + await prisma.connect(); + const mod = await import('../../src/app'); + app = mod.app; + + fetchSpy = vi.spyOn(globalThis, 'fetch'); +}); + +afterAll(async () => { + const { PrismaService } = await import('../../src/db/prisma-client'); + await PrismaService.getInstance().disconnect(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + fetchSpy = vi.spyOn(globalThis, 'fetch'); +}); + +describe('GET /api/v1/analytics/transfers', () => { + describe('200 success responses', () => { + it('should return 200 with transfer data when wallet is provided', async () => { + const walletAddress = createUniqueAddress(); + + const mockData = { + transfers: [ + { + id: 'transfer-1', + fromAddress: walletAddress, + toAddress: '0x876543210fedcba9876543210fedcba987654321', + fromChain: 1, + toChain: 137, + amount: '1000000000000000000', + status: 'COMPLETED', + timestamp: '2024-01-15T10:30:00Z' + } + ], + hasNext: false, + hasPrevious: false, + next: null, + previous: null + }; + + fetchSpy.mockResolvedValueOnce(createMockResponse(mockData) as unknown as Response); + + const res = await request(app) + .get('/api/v1/analytics/transfers') + .query({ wallet: walletAddress }); + + expect(res.status).toBe(200); + expect(res.body.transfers).toHaveLength(1); + expect(res.body.transfers[0].id).toBe('transfer-1'); + }); + + it('should apply status filter when status parameter is provided', async () => { + const walletAddress = createUniqueAddress(); + + const mockData = { + transfers: [{ id: 'transfer-2', status: 'PENDING' }], + hasNext: false, + hasPrevious: false, + next: null, + previous: null + }; + + fetchSpy.mockResolvedValueOnce(createMockResponse(mockData) as unknown as Response); + + const res = await request(app) + .get('/api/v1/analytics/transfers') + .query({ wallet: walletAddress, status: 'PENDING' }); + + expect(res.status).toBe(200); + expect(res.body.transfers[0].status).toBe('PENDING'); + }); + + it('should apply time filters when fromTime and toTime parameters are provided', async () => { + const walletAddress = createUniqueAddress(); + const mockData = { transfers: [], hasNext: false, hasPrevious: false, next: null, previous: null }; + + fetchSpy.mockResolvedValueOnce(createMockResponse(mockData) as unknown as Response); + + const res = await request(app) + .get('/api/v1/analytics/transfers') + .query({ wallet: walletAddress, fromTime: '2024-01-01T00:00:00Z', toTime: '2024-01-31T23:59:59Z' }); + + expect(res.status).toBe(200); + }); + + it('should apply pagination limit when limit parameter is provided', async () => { + const walletAddress = createUniqueAddress(); + + const mockData = { + transfers: [{ id: 'transfer-3' }], + hasNext: true, + hasPrevious: false, + next: 'cursor-123', + previous: null + }; + + fetchSpy.mockResolvedValueOnce(createMockResponse(mockData) as unknown as Response); + + const res = await request(app) + .get('/api/v1/analytics/transfers') + .query({ wallet: walletAddress, limit: 5 }); + + expect(res.status).toBe(200); + expect(res.body.hasNext).toBe(true); + expect(res.body.next).toBe('cursor-123'); + }); + }); + + describe('400 error responses', () => { + it('should return 400 when wallet parameter is missing', async () => { + const res = await request(app) + .get('/api/v1/analytics/transfers'); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + it('should return 400 when wallet address is invalid', async () => { + const res = await request(app) + .get('/api/v1/analytics/transfers') + .query({ wallet: 'invalid-address' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + }); + + describe('502 upstream error handling', () => { + it('should return 502 when LI.FI API returns 500 error', async () => { + const walletAddress = createUniqueAddress(); + + fetchSpy.mockResolvedValueOnce(createErrorResponse(500, 'Internal Server Error') as unknown as Response); + + const res = await request(app) + .get('/api/v1/analytics/transfers') + .query({ wallet: walletAddress }); + + expect(res.status).toBe(502); + }); + + it('should fallback to local DB when LI.FI API times out', async () => { + const walletAddress = createUniqueAddress(); + + fetchSpy.mockRejectedValueOnce(new Error('Network timeout')); + + const res = await request(app) + .get('/api/v1/analytics/transfers') + .query({ wallet: walletAddress }); + + expect(res.status).toBe(200); + expect(res.body._bridgeShield?.fallbackUsed).toBe(true); + }); + }); + + describe('429 rate limit handling', () => { + it('should return 429 when LI.FI API rate limits us', async () => { + const walletAddress = createUniqueAddress(); + const headers = new Map([ + ['X-RateLimit-Limit', '100'], + ['X-RateLimit-Remaining', '0'], + ['X-RateLimit-Reset', '1705334400'] + ]); + + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + json: async () => ({ error: 'Rate limit exceeded' }), + text: async () => '429 Too Many Requests', + headers + } as unknown as Response); + + const res = await request(app) + .get('/api/v1/analytics/transfers') + .query({ wallet: walletAddress }); + + expect(res.status).toBe(429); + }); + }); + + describe('caching behavior', () => { + it('should cache responses for identical queries', async () => { + const walletAddress = createUniqueAddress(); + + const mockData = { + transfers: [{ id: 'cached' }], + hasNext: false, + hasPrevious: false, + next: null, + previous: null + }; + + fetchSpy.mockResolvedValue(createMockResponse(mockData) as unknown as Response); + + const res1 = await request(app) + .get('/api/v1/analytics/transfers') + .query({ wallet: walletAddress }); + + expect(res1.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + const res2 = await request(app) + .get('/api/v1/analytics/transfers') + .query({ wallet: walletAddress }); + + expect(res2.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/backend/tests/integration/api.test.ts b/backend/tests/integration/api.test.ts index aa20d6f..abc893b 100644 --- a/backend/tests/integration/api.test.ts +++ b/backend/tests/integration/api.test.ts @@ -6,12 +6,32 @@ import { CacheService } from '../../src/services/cache-service'; let app: Express; let uniqueCounter = 0; +const DEMO_API_KEY = process.env.DEMO_API_KEY || 'demo-test-api-key'; +const ADMIN_USERNAME = process.env.ADMIN_INIT_USERNAME || 'admin'; +const ADMIN_PASSWORD = process.env.ADMIN_INIT_PASSWORD || 'admin-password'; const createUniqueAddress = () => { uniqueCounter += 1; return `0x${(Date.now() + uniqueCounter).toString(16).padStart(40, '0')}`.slice(0, 42); }; +const withApiKey = (req: any) => req.set('x-api-key', DEMO_API_KEY); + +const withAdminToken = (req: any, token: string) => req.set('Authorization', `Bearer ${token}`); + +const getAdminAccessToken = async (): Promise => { + const response = await request(app) + .post('/api/v1/admin/auth/login') + .send({ + username: ADMIN_USERNAME, + password: ADMIN_PASSWORD, + }); + + expect(response.status).toBe(200); + expect(response.body.accessToken).toBeDefined(); + return response.body.accessToken; +}; + beforeAll(async () => { const prisma = PrismaService.getInstance(); await prisma.connect(); @@ -42,12 +62,12 @@ describe('GET /api/v1/health', () => { describe('POST /api/v1/aml/check', () => { it('returns BLOCK for known hacker address', async () => { - const res = await request(app) + const res = await withApiKey(request(app) .post('/api/v1/aml/check') .send({ address: '0x098B716B8Aaf21512996dC57EB0615e2383E2f96', chainId: 1, - }); + })); expect(res.status).toBe(200); expect(res.body.decision).toBe('BLOCK'); expect(res.body.riskScore).toBeGreaterThanOrEqual(70); @@ -55,12 +75,12 @@ describe('POST /api/v1/aml/check', () => { }); it('returns ALLOW for whitelisted address', async () => { - const res = await request(app) + const res = await withApiKey(request(app) .post('/api/v1/aml/check') .send({ address: '0x1F98431c8aD98523631AE4a59f267346ea31F984', chainId: 1, - }); + })); expect(res.status).toBe(200); expect(res.body.decision).toBe('ALLOW'); expect(res.body.isWhitelisted).toBe(true); @@ -68,46 +88,46 @@ describe('POST /api/v1/aml/check', () => { }); it('returns ALLOW for clean unknown address', async () => { - const res = await request(app) + const res = await withApiKey(request(app) .post('/api/v1/aml/check') .send({ address: '0x0000000000000000000000000000000000000001', chainId: 1, - }); + })); expect(res.status).toBe(200); expect(res.body.decision).toBe('ALLOW'); expect(res.body.riskScore).toBe(0); }); it('returns 400 for invalid address', async () => { - const res = await request(app) + const res = await withApiKey(request(app) .post('/api/v1/aml/check') - .send({ address: 'not-valid', chainId: 1 }); + .send({ address: 'not-valid', chainId: 1 })); expect(res.status).toBe(400); expect(res.body.error).toBe('Validation failed'); }); it('returns 400 for missing address', async () => { - const res = await request(app) + const res = await withApiKey(request(app) .post('/api/v1/aml/check') - .send({ chainId: 1 }); + .send({ chainId: 1 })); expect(res.status).toBe(400); }); it('returns 400 for missing chainId', async () => { - const res = await request(app) + const res = await withApiKey(request(app) .post('/api/v1/aml/check') - .send({ address: '0x098B716B8Aaf21512996dC57EB0615e2383E2f96' }); + .send({ address: '0x098B716B8Aaf21512996dC57EB0615e2383E2f96' })); expect(res.status).toBe(400); }); it('returns cached result on second call', async () => { const addr = '0x098B716B8Aaf21512996dC57EB0615e2383E2f96'; - await request(app).post('/api/v1/aml/check').send({ address: addr, chainId: 1 }); + await withApiKey(request(app).post('/api/v1/aml/check').send({ address: addr, chainId: 1 })); - const res = await request(app) + const res = await withApiKey(request(app) .post('/api/v1/aml/check') - .send({ address: addr, chainId: 1 }); + .send({ address: addr, chainId: 1 })); expect(res.status).toBe(200); expect(res.body.cacheHit).toBe(true); }); @@ -115,24 +135,24 @@ describe('POST /api/v1/aml/check', () => { it('escalates ALLOW to REVIEW when behavior anomaly is detected', async () => { const uniqueAddress = createUniqueAddress(); - const first = await request(app) + const first = await withApiKey(request(app) .post('/api/v1/aml/check') .send({ address: uniqueAddress, chainId: 1, amount: '10' - }); + })); expect(first.status).toBe(200); expect(first.body.decision).toBe('ALLOW'); - const second = await request(app) + const second = await withApiKey(request(app) .post('/api/v1/aml/check') .send({ address: uniqueAddress, chainId: 1, amount: '1000000' - }); + })); expect(second.status).toBe(200); expect(second.body.decision).toBe('REVIEW'); @@ -143,7 +163,7 @@ describe('POST /api/v1/aml/check', () => { describe('GET /api/v1/aml/whitelist', () => { it('returns whitelist summary', async () => { - const res = await request(app).get('/api/v1/aml/whitelist'); + const res = await withApiKey(request(app).get('/api/v1/aml/whitelist')); expect(res.status).toBe(200); expect(res.body.total).toBeGreaterThan(0); expect(res.body.categories).toBeDefined(); @@ -190,13 +210,13 @@ describe('GET /api/v1/composer/quote', () => { it('returns REVIEW for clean address when behavior anomaly is detected', async () => { const uniqueAddress = createUniqueAddress(); - await request(app) + await withApiKey(request(app) .post('/api/v1/aml/check') .send({ address: uniqueAddress, chainId: 8453, amount: '10' - }); + })); const res = await request(app) .get('/api/v1/composer/quote') @@ -217,13 +237,13 @@ describe('GET /api/v1/behavior/profile/:wallet', () => { it('returns behavior profile for wallet', async () => { const address = createUniqueAddress(); - await request(app) + await withApiKey(request(app) .post('/api/v1/aml/check') .send({ address, chainId: 1, amount: '100' - }); + })); const res = await request(app) .get(`/api/v1/behavior/profile/${address}`) @@ -239,14 +259,14 @@ describe('GET /api/v1/behavior/profile/:wallet', () => { describe('POST /api/v1/aml/appeal', () => { it('creates appeal successfully', async () => { const uniqueAddress = createUniqueAddress(); - const res = await request(app) + const res = await withApiKey(request(app) .post('/api/v1/aml/appeal') .send({ address: uniqueAddress, chainId: 1, reason: 'This is my personal cold wallet, never interacted with risky addresses', contact: 'user@example.com', - }); + })); expect(res.status).toBe(201); expect(res.body.ticketId).toMatch(/^APT-/); expect(res.body.status).toBe('PENDING'); @@ -254,54 +274,97 @@ describe('POST /api/v1/aml/appeal', () => { it('returns 400 for missing reason', async () => { const uniqueAddress = createUniqueAddress(); - const res = await request(app) + const res = await withApiKey(request(app) .post('/api/v1/aml/appeal') .send({ address: uniqueAddress, chainId: 1, - }); + })); expect(res.status).toBe(400); }); it('returns 400 for invalid address', async () => { - const res = await request(app) + const res = await withApiKey(request(app) .post('/api/v1/aml/appeal') .send({ address: 'bad-address', chainId: 1, reason: 'test', - }); + })); expect(res.status).toBe(400); }); }); +describe('Authentication enforcement', () => { + it('rejects admin request without credentials', async () => { + const res = await request(app).get('/api/v1/admin/appeals'); + expect(res.status).toBe(401); + }); + + it('rejects AML request without api key', async () => { + const res = await request(app) + .post('/api/v1/aml/check') + .send({ + address: '0x0000000000000000000000000000000000000001', + chainId: 1, + }); + expect(res.status).toBe(401); + }); + + it('issues admin JWT for valid login', async () => { + const res = await request(app) + .post('/api/v1/admin/auth/login') + .send({ + username: ADMIN_USERNAME, + password: ADMIN_PASSWORD, + }); + + expect(res.status).toBe(200); + expect(res.body.accessToken).toBeDefined(); + expect(res.body.expiresIn).toBe(43200); + expect(res.body.user.username).toBe(ADMIN_USERNAME); + }); + + it('allows admin endpoint with JWT token', async () => { + const token = await getAdminAccessToken(); + const res = await withAdminToken(request(app).get('/api/v1/admin/appeals'), token); + expect(res.status).toBe(200); + }); + + it('allows admin endpoint with demo api key', async () => { + const res = await withApiKey(request(app).get('/api/v1/admin/appeals')); + expect(res.status).toBe(200); + }); +}); + describe('POST /api/v1/admin/appeal/:id/approve', () => { it('approves an appeal without creating a duplicate whitelist entry', async () => { const uniqueAddress = createUniqueAddress(); + const adminToken = await getAdminAccessToken(); - const appealResponse = await request(app) + const appealResponse = await withApiKey(request(app) .post('/api/v1/aml/appeal') .send({ address: uniqueAddress, chainId: 1, reason: 'Please review and approve', contact: 'user@example.com', - }); + })); expect(appealResponse.status).toBe(201); - const appealsResponse = await request(app).get('/api/v1/admin/appeals'); + const appealsResponse = await withAdminToken(request(app).get('/api/v1/admin/appeals'), adminToken); const createdAppeal = appealsResponse.body.find((appeal: any) => appeal.address === uniqueAddress.toLowerCase()); expect(createdAppeal).toBeDefined(); - const approveResponse = await request(app) + const approveResponse = await withAdminToken(request(app) .post(`/api/v1/admin/appeal/${createdAppeal.id}/approve`) - .send(); + .send(), adminToken); expect(approveResponse.status).toBe(200); expect(approveResponse.body.success).toBe(true); - const whitelistResponse = await request(app).get('/api/v1/admin/whitelist'); + const whitelistResponse = await withAdminToken(request(app).get('/api/v1/admin/whitelist'), adminToken); const whitelistEntries = whitelistResponse.body.filter((entry: any) => entry.address === uniqueAddress.toLowerCase()); expect(whitelistEntries).toHaveLength(1); expect(whitelistEntries[0].type).toBe('APPEAL_APPROVED'); @@ -313,32 +376,33 @@ describe('POST /api/v1/admin/appeal/:id/reject', () => { it('rejects an appeal and removes temporary whitelist access from db and cache', async () => { const uniqueAddress = createUniqueAddress(); const cacheService = CacheService.getInstance(); + const adminToken = await getAdminAccessToken(); - const appealResponse = await request(app) + const appealResponse = await withApiKey(request(app) .post('/api/v1/aml/appeal') .send({ address: uniqueAddress, chainId: 1, reason: 'Please review and reject', contact: 'user@example.com', - }); + })); expect(appealResponse.status).toBe(201); expect(cacheService.get(uniqueAddress, 1)).not.toBeNull(); - const appealsResponse = await request(app).get('/api/v1/admin/appeals'); + const appealsResponse = await withAdminToken(request(app).get('/api/v1/admin/appeals'), adminToken); const createdAppeal = appealsResponse.body.find((appeal: any) => appeal.address === uniqueAddress.toLowerCase()); expect(createdAppeal).toBeDefined(); - const rejectResponse = await request(app) + const rejectResponse = await withAdminToken(request(app) .post(`/api/v1/admin/appeal/${createdAppeal.id}/reject`) - .send({ notes: 'Rejected during test' }); + .send({ notes: 'Rejected during test' }), adminToken); expect(rejectResponse.status).toBe(200); expect(rejectResponse.body.success).toBe(true); expect(cacheService.get(uniqueAddress, 1)).toBeNull(); - const whitelistResponse = await request(app).get('/api/v1/admin/whitelist'); + const whitelistResponse = await withAdminToken(request(app).get('/api/v1/admin/whitelist'), adminToken); const whitelistEntries = whitelistResponse.body.filter((entry: any) => entry.address === uniqueAddress.toLowerCase()); expect(whitelistEntries).toHaveLength(0); }); diff --git a/backend/tests/setup.ts b/backend/tests/setup.ts index 9caf3e1..e373c29 100644 --- a/backend/tests/setup.ts +++ b/backend/tests/setup.ts @@ -6,6 +6,10 @@ process.env.NODE_ENV = 'test'; process.env.LOG_LEVEL = 'error'; // Use the same dev.db to avoid PrismaClient singleton mismatch process.env.DATABASE_URL = 'file:./dev.db'; +process.env.JWT_SECRET = 'test-jwt-secret-for-bridgeshield'; +process.env.ADMIN_INIT_USERNAME = 'admin'; +process.env.ADMIN_INIT_PASSWORD = 'admin-password'; +process.env.DEMO_API_KEY = 'demo-test-api-key'; beforeAll(async () => { // Ensure schema is pushed to dev.db diff --git a/backend/tests/unit/analytics-service.test.ts b/backend/tests/unit/analytics-service.test.ts new file mode 100644 index 0000000..2c8e2b1 --- /dev/null +++ b/backend/tests/unit/analytics-service.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'; +import { AnalyticsService, TransferFilters } from '../../src/services/analytics-service'; + +describe('AnalyticsService', () => { + let analyticsService: AnalyticsService; + let fetchSpy: ReturnType; + + const createMockResponse = (data: unknown) => ({ + ok: true, + json: async () => data, + headers: new Map(), + status: 200, + statusText: 'OK' + }); + + const createErrorResponse = (status: number, statusText: string) => ({ + ok: false, + status, + statusText, + json: async () => ({ error: `${status} error` }), + headers: new Map(), + text: async () => `${status} ${statusText}` + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + analyticsService = new AnalyticsService(); + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('fetchTransfers', () => { + it('should fetch transfers for a wallet address', async () => { + const mockData = { + transfers: [ + { + id: 'transfer-1', + fromAddress: '0x1234567890abcdef1234567890abcdef12345678', + toAddress: '0x876543210fedcba9876543210fedcba987654321', + fromChain: 1, + toChain: 137, + amount: '1000000000000000000', + status: 'COMPLETED', + timestamp: '2024-01-15T10:30:00Z' + } + ], + hasNext: false, + hasPrevious: false, + next: null, + previous: null + }; + + fetchSpy.mockResolvedValueOnce(createMockResponse(mockData) as unknown as Response); + + const result = await analyticsService.fetchTransfers('0x1234567890abcdef1234567890abcdef12345678'); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://li.quest/v2/analytics/transfers?wallet=0x1234567890abcdef1234567890abcdef12345678', + expect.objectContaining({ method: 'GET' }) + ); + expect(result.transfers).toHaveLength(1); + expect(result.transfers[0].id).toBe('transfer-1'); + }); + + it('should handle pagination with next/previous cursors', async () => { + const mockData = { + transfers: [{ id: 'transfer-2', status: 'PENDING' }], + hasNext: true, + hasPrevious: false, + next: 'cursor-123', + previous: null + }; + + fetchSpy.mockResolvedValueOnce(createMockResponse(mockData) as unknown as Response); + + const result = await analyticsService.fetchTransfers('0x1234567890abcdef1234567890abcdef12345678', { cursor: 'cursor-123' }); + + expect(result.hasNext).toBe(true); + expect(result.next).toBe('cursor-123'); + }); + + it('should apply filters (status, time range, chains)', async () => { + const mockData = { transfers: [], hasNext: false, hasPrevious: false, next: null, previous: null }; + fetchSpy.mockResolvedValueOnce(createMockResponse(mockData) as unknown as Response); + + const filters: TransferFilters = { + status: 'PENDING', + fromChain: 1, + toChain: 137, + fromTimestamp: '2024-01-01T00:00:00Z', + toTimestamp: '2024-01-31T23:59:59Z' + }; + + await analyticsService.fetchTransfers('0x1234567890abcdef1234567890abcdef12345678', filters); + + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('status=PENDING'); + expect(url).toContain('fromChain=1'); + expect(url).toContain('toChain=137'); + }); + + it('should handle rate limit errors (429)', async () => { + fetchSpy.mockResolvedValueOnce(createErrorResponse(429, 'Too Many Requests') as unknown as Response); + + await expect(analyticsService.fetchTransfers('0x1234567890abcdef1234567890abcdef12345678')).rejects.toThrow('429'); + }); + + it('should handle validation errors (400)', async () => { + fetchSpy.mockResolvedValueOnce(createErrorResponse(400, 'Bad Request') as unknown as Response); + + await expect(analyticsService.fetchTransfers('0x1234567890abcdef1234567890abcdef12345678')).rejects.toThrow('400'); + }); + + it('should handle server errors (500)', async () => { + fetchSpy.mockResolvedValueOnce(createErrorResponse(500, 'Internal Server Error') as unknown as Response); + + await expect(analyticsService.fetchTransfers('0x1234567890abcdef1234567890abcdef12345678')).rejects.toThrow('500'); + }); + }); + + describe('transformResponse', () => { + it('should transform LI.FI response to internal format', () => { + const liFiResponse = { + transfers: [{ + id: 'transfer-3', + fromAddress: '0x1234567890abcdef1234567890abcdef12345678', + toAddress: '0x876543210fedcba9876543210fedcba987654321', + fromChain: 1, + toChain: 137, + amount: '3000000000000000000', + status: 'COMPLETED', + timestamp: '2024-01-15T12:30:00Z', + txHash: '0xabc123def456' + }], + hasNext: false, + hasPrevious: false, + next: null, + previous: null + }; + + const result = analyticsService.transformResponse(liFiResponse); + + expect(result.transfers[0].id).toBe('transfer-3'); + expect(result.transfers[0].txHash).toBe('0xabc123def456'); + expect(result.hasNext).toBe(false); + }); + }); + + describe('caching', () => { + it('should cache results for same query', async () => { + const mockData = { transfers: [{ id: 'cached' }], hasNext: false, hasPrevious: false, next: null, previous: null }; + fetchSpy.mockResolvedValueOnce(createMockResponse(mockData) as unknown as Response); + + const result1 = await analyticsService.fetchTransfers('0x1234567890abcdef1234567890abcdef12345678'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + const result2 = await analyticsService.fetchTransfers('0x1234567890abcdef1234567890abcdef12345678'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result2.transfers).toEqual(result1.transfers); + }); + + it('should respect cache TTL (15 minutes)', async () => { + const mockData = { transfers: [], hasNext: false, hasPrevious: false, next: null, previous: null }; + fetchSpy.mockResolvedValueOnce(createMockResponse(mockData) as unknown as Response); + + await analyticsService.fetchTransfers('0x1234567890abcdef1234567890abcdef12345678'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(16 * 60 * 1000); + + await analyticsService.fetchTransfers('0x1234567890abcdef1234567890abcdef12345678'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('rate limit headers', () => { + it('should capture rate limit headers', async () => { + const mockData = { transfers: [], hasNext: false, hasPrevious: false, next: null, previous: null }; + const headers = new Map([ + ['X-RateLimit-Limit', '100'], + ['X-RateLimit-Remaining', '95'], + ['X-RateLimit-Reset', '1705334400'] + ]); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + headers, + status: 200, + statusText: 'OK' + } as unknown as Response); + + await analyticsService.fetchTransfers('0x1234567890abcdef1234567890abcdef12345678'); + + const rateLimitInfo = analyticsService.getRateLimitInfo(); + expect(rateLimitInfo?.limit).toBe(100); + expect(rateLimitInfo?.remaining).toBe(95); + }); + }); +}); diff --git a/backend/tests/unit/behavior-analyzer-lifi.test.ts b/backend/tests/unit/behavior-analyzer-lifi.test.ts new file mode 100644 index 0000000..0c487ca --- /dev/null +++ b/backend/tests/unit/behavior-analyzer-lifi.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { BehaviorAnalyzerService, BehaviorProfile } from '../../src/services/behavior-analyzer'; +import { Transfer } from '../../src/services/analytics-service'; + +describe('BehaviorAnalyzerService - LI.FI Analytics Integration', () => { + let service: BehaviorAnalyzerService; + const now = new Date('2026-04-12T00:00:00.000Z'); + + const createTransfer = (partial: Partial): Transfer => ({ + id: `tx-${Math.random().toString(36).slice(2)}`, + fromAddress: '0x1234567890abcdef1234567890abcdef12345678', + toAddress: '0x876543210fedcba9876543210fedcba987654321', + fromChain: 1, + toChain: 137, + amount: '1000000000000000000', + status: 'COMPLETED', + timestamp: new Date().toISOString(), + ...partial + }); + + beforeEach(() => { + vi.clearAllMocks(); + service = new BehaviorAnalyzerService(); + }); + + describe('LI.FI History Enhancement', () => { + it('should increase score when LI.FI history shows high-risk address interactions', async () => { + const lifiHistory: Transfer[] = [ + createTransfer({ + toAddress: '0x098B716B8Aaf21512996dC57EB0615e2383E2f96', + amount: '5000000000000000000', + timestamp: new Date(now.getTime() - 86400000).toISOString() + }), + createTransfer({ + toAddress: '0x098B716B8Aaf21512996dC57EB0615e2383E2f96', + amount: '3000000000000000000', + timestamp: new Date(now.getTime() - 172800000).toISOString() + }) + ]; + + const localHistory = []; + const currentInput = { + address: '0x1234567890abcdef1234567890abcdef12345678', + chainId: 1, + amount: '1000000' + }; + + const profile = await service.calculateProfile(currentInput, localHistory, now, lifiHistory); + + expect(profile.score).toBeGreaterThan(0); + expect(profile.lifiSignals?.length).toBeGreaterThan(0); + }); + + it('should increase score when LI.FI history shows amount spike vs historical average', async () => { + const lifiHistory: Transfer[] = Array.from({ length: 10 }).map((_, i) => + createTransfer({ + amount: (BigInt(1000) * BigInt(10 ** 18)).toString(), + timestamp: new Date(now.getTime() - i * 86400000).toISOString() + }) + ); + + const localHistory = []; + const currentInput = { + address: '0x1234567890abcdef1234567890abcdef12345678', + chainId: 1, + amount: (BigInt(50000) * BigInt(10 ** 18)).toString() + }; + + const profile = await service.calculateProfile(currentInput, localHistory, now, lifiHistory); + + expect(profile.score).toBeGreaterThan(0); + expect(profile.lifiSignals?.some(s => s.includes('LI.FI'))).toBe(true); + }); + + it('should increase score for first-time LI.FI user with high transaction value', async () => { + const lifiHistory: Transfer[] = [ + createTransfer({ + amount: (BigInt(100000) * BigInt(10 ** 18)).toString(), + timestamp: new Date(now.getTime() - 3600000).toISOString() + }) + ]; + + const localHistory = []; + const currentInput = { + address: '0x1234567890abcdef1234567890abcdef12345678', + chainId: 8453, + amount: (BigInt(100000) * BigInt(10 ** 18)).toString() + }; + + const profile = await service.calculateProfile(currentInput, localHistory, now, lifiHistory); + + expect(profile.score).toBeGreaterThanOrEqual(20); + }); + + it('should NOT override BLOCK decision even with suspicious LI.FI history', async () => { + const lifiHistory: Transfer[] = [ + createTransfer({ + toAddress: '0x098B716B8Aaf21512996dC57EB0615e2383E2f96', + amount: (BigInt(1000000) * BigInt(10 ** 18)).toString() + }) + ]; + + const localHistory = [{ + createdAt: new Date(now.getTime() - 86400000), + chainId: 1, + decision: 'BLOCK' as const, + amount: 1 + }]; + + const currentInput = { + address: '0x1234567890abcdef1234567890abcdef12345678', + chainId: 1, + amount: '1000000' + }; + + const profile = await service.calculateProfile(currentInput, localHistory, now, lifiHistory); + + const adjustment = service.applyBehaviorAdjustment({ + decision: 'BLOCK', + riskLevel: 'HIGH', + riskScore: 90 + }, profile); + + expect(adjustment.decision).toBe('BLOCK'); + }); + + it('should combine local checkLog and LI.FI history for better confidence', async () => { + const lifiHistory: Transfer[] = Array.from({ length: 5 }).map((_, i) => + createTransfer({ + amount: (BigInt(1000) * BigInt(10 ** 18)).toString(), + timestamp: new Date(now.getTime() - i * 86400000).toISOString() + }) + ); + + const localHistory = Array.from({ length: 3 }).map((_, i) => ({ + createdAt: new Date(now.getTime() - i * 86400000), + chainId: 1, + decision: 'ALLOW' as const, + amount: 1000 + })); + + const currentInput = { + address: '0x1234567890abcdef1234567890abcdef12345678', + chainId: 1, + amount: '1200' + }; + + const profileNoLifi = await service.calculateProfile(currentInput, localHistory, now, []); + const profileWithLifi = await service.calculateProfile(currentInput, localHistory, now, lifiHistory); + + expect(profileWithLifi.confidence).toBe('HIGH'); + }); + + it('should handle empty LI.FI history gracefully', async () => { + const lifiHistory: Transfer[] = []; + const localHistory = [{ + createdAt: new Date(now.getTime() - 86400000), + chainId: 1, + decision: 'ALLOW' as const, + amount: 100 + }]; + + const currentInput = { + address: '0x1234567890abcdef1234567890abcdef12345678', + chainId: 1, + amount: '100' + }; + + const profile = await service.calculateProfile(currentInput, localHistory, now, lifiHistory); + + expect(profile.level).toBe('LOW'); + expect(profile.score).toBeLessThan(30); + }); + + it('should apply LI.FI-based escalation (ALLOW to REVIEW)', async () => { + const lifiHistory: Transfer[] = Array.from({ length: 20 }).map((_, i) => + createTransfer({ + toAddress: i % 3 === 0 ? '0x098B716B8Aaf21512996dC57EB0615e2383E2f96' : createTransfer({}).toAddress, + amount: (BigInt(10000) * BigInt(10 ** 18)).toString(), + timestamp: new Date(now.getTime() - i * 3600000).toISOString() + }) + ); + + const localHistory = []; + const currentInput = { + address: '0x1234567890abcdef1234567890abcdef12345678', + chainId: 8453, + amount: (BigInt(50000) * BigInt(10 ** 18)).toString() + }; + + const profile = await service.calculateProfile(currentInput, localHistory, now, lifiHistory); + + const adjustment = service.applyBehaviorAdjustment({ + decision: 'ALLOW', + riskLevel: 'LOW', + riskScore: 0 + }, profile); + + expect(adjustment.decision).toBe('REVIEW'); + expect(adjustment.behaviorEscalated).toBe(true); + }); + + it('should set fallbackUsed flag when LI.FI API fails or returns empty', async () => { + const lifiHistory: Transfer[] = []; + const localHistory = [{ + createdAt: new Date(now.getTime() - 86400000), + chainId: 1, + decision: 'ALLOW' as const, + amount: 100 + }]; + + const currentInput = { + address: '0x1234567890abcdef1234567890abcdef12345678', + chainId: 1, + amount: '100' + }; + + const profile = await service.calculateProfile(currentInput, localHistory, now, lifiHistory); + + expect(profile.metrics.lifiHistoryFallback).toBe(true); + }); + }); + + describe('Risk Signal Extraction from LI.FI', () => { + it('should detect cross-chain tumbling patterns', async () => { + const lifiHistory: Transfer[] = Array.from({ length: 10 }).map((_, i) => + createTransfer({ + fromChain: (i % 5) + 1, + toChain: ((i + 1) % 5) + 1, + amount: (BigInt(1000) * BigInt(10 ** 18)).toString(), + timestamp: new Date(now.getTime() - i * 3600000).toISOString() + }) + ); + + const localHistory = []; + const currentInput = { + address: '0x1234567890abcdef1234567890abcdef12345678', + chainId: 1, + amount: '1000' + }; + + const profile = await service.calculateProfile(currentInput, localHistory, now, lifiHistory); + + expect(profile.lifiSignals?.some(s => + s.toLowerCase().includes('cross-chain') || + s.toLowerCase().includes('li.fi') + )).toBe(true); + }); + }); +}); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 4f37e48..7d53ca6 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,6 +9,10 @@ services: - NODE_ENV=development - DATABASE_URL=file:./dev.db - LOG_LEVEL=debug + - JWT_SECRET=bridgeshield-docker-dev-jwt-secret + - ADMIN_INIT_USERNAME=admin + - ADMIN_INIT_PASSWORD=admin-password + - DEMO_API_KEY=bridgeshield-demo-key volumes: - ./backend:/app - /app/node_modules @@ -22,7 +26,8 @@ services: - ./frontend-demo:/app - /app/node_modules environment: - - VITE_API_URL=http://localhost:3000 + - VITE_API_BASE_URL=http://localhost:3000 + - VITE_API_KEY=bridgeshield-demo-key command: npm run dev frontend-admin: @@ -33,5 +38,5 @@ services: - ./frontend-admin:/app - /app/node_modules environment: - - VITE_API_URL=http://localhost:3000 - command: npm run dev \ No newline at end of file + - VITE_API_BASE_URL=http://localhost:3000 + command: npm run dev diff --git a/docker-compose.yml b/docker-compose.yml index 64a39a0..367aa05 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,10 @@ services: - NODE_ENV=development - DATABASE_URL=file:./dev.db - LOG_LEVEL=info + - JWT_SECRET=bridgeshield-docker-jwt-secret-change-me + - ADMIN_INIT_USERNAME=admin + - ADMIN_INIT_PASSWORD=admin-password + - DEMO_API_KEY=bridgeshield-demo-key volumes: - ./backend/data:/app/data restart: unless-stopped @@ -33,4 +37,4 @@ services: - "5174:80" restart: unless-stopped depends_on: - - backend \ No newline at end of file + - backend diff --git a/docs/dev_docs-0.0.7.md b/docs/dev_docs-0.0.7.md new file mode 100644 index 0000000..700ddcc --- /dev/null +++ b/docs/dev_docs-0.0.7.md @@ -0,0 +1,1894 @@ +# BridgeShield v0.0.7 完整工程设计文档 + +> **项目**:BridgeShield — LI.FI 跨链交易 AML 入口检查系统 +> **版本**:v0.0.7 +> **文档状态**:工程执行版(含 Demo 前端、Admin 前端、SDK) +> **目标读者**:开发团队、技术评审 + +--- + +## 目录 + +1. [项目定位与范围](#1-项目定位与范围) +2. [整体目录结构](#2-整体目录结构) +3. [系统架构](#3-系统架构) +4. [黑名单数据来源与管理](#4-黑名单数据来源与管理) +5. [地址扫描与资金溯源](#5-地址扫描与资金溯源) +6. [风险评分模型](#6-风险评分模型) +7. [核心 API 规格](#7-核心-api-规格) +8. [数据库设计](#8-数据库设计) +9. [缓存策略](#9-缓存策略) +10. [熔断与降级机制](#10-熔断与降级机制) +11. [Demo 前端(独立目录)](#11-demo-前端独立目录) +12. [后台管理前端(独立目录)](#12-后台管理前端独立目录) +13. [LI.FI 集成方案](#13-lifi-集成方案) +14. [部署架构](#14-部署架构) +15. [监控与告警](#15-监控与告警) +16. [开发计划](#16-开发计划) + +--- + +## 1. 项目定位与范围 + +### 1.1 核心定位 + +BridgeShield 是**专为 LI.FI 跨链交易设计的前置式 AML 入口检查网关**,在资金进入 LI.FI 跨链网络的第一秒完成风险筛查。 + +> 类比:机场安检——不在飞机起飞后检查乘客,而是在登机口完成所有检查。 + +### 1.2 三个独立子项目 + +| 子项目 | 目录 | 定位 | 优先级 | +|--------|------|------|--------| +| **后端 API** | `backend/` | 核心风险引擎、数据管理、API 服务 | P0 必须 | +| **Demo 前端** | `frontend-demo/` | 黑客松演示、LI.FI 对比展示、说服评审 | P0 必须 | +| **后台管理** | `frontend-admin/` | 白名单管理、申诉审核、数据监控 | P1 加分 | + +**关键区分**:Demo 前端面向**评审和外部集成商**,后台管理面向**内部运营人员**,两者目的、用户、风格完全不同,必须分开。 + +### 1.3 MVP 功能范围 + +**后端 MVP(P0):** + +| 功能 | API 端点 | 优先级 | +|------|----------|--------| +| 地址风险检查 | `POST /api/v1/aml/check` | P0 | +| 白名单查询 | `GET /api/v1/aml/whitelist` | P0 | +| 误判申诉 | `POST /api/v1/aml/appeal` | P0 | +| 健康检查 | `GET /api/v1/health` | P0 | + +**Demo 前端 MVP(P0):** + +| 功能 | 说明 | +|------|------| +| 地址风险检查页 | 输入地址 → 实时显示风险评分、因子、操作决策 | +| LI.FI 对比演示 | 左侧展示"无 AML",右侧展示"有 BridgeShield" | +| 预设演示地址 | Ronin 黑客、Tornado、正常地址等一键切换 | +| 实时统计展示 | 今日检查数、拦截数、响应时间 | +| 集成代码展示 | 3 行代码接入 LI.FI,增强说服力 | + +**后台管理 MVP(P1):** + +| 功能 | 说明 | +|------|------| +| 风险仪表盘 | 检查总量、风险分布、拦截趋势图 | +| 申诉管理 | 查看申诉列表、审批通过/驳回 | +| 白名单管理 | 添加/删除/查询白名单地址 | + +**砍掉项(不做):** +- 图数据库分析 +- AI/ML 评分模型 +- 用户注册/登录系统 +- 非 EVM 链支持 + +### 1.4 性能目标 + +| 指标 | 目标值 | 说明 | +|------|--------|------| +| API P95 响应时间 | < 100ms | 缓存命中场景 | +| API P99 响应时间 | < 200ms | 冷查询场景 | +| 单节点吞吐量 | 1000+ QPS | 内存缓存支撑 | +| 缓存命中率 | > 70% | 高频地址复用 | +| 系统误判率 | < 0.1% | 白名单机制控制 | +| Demo 页面加载 | < 2s | 首屏时间 | + +--- + +## 2. 整体目录结构 + +``` +bridgeshield/ +├── backend/ # 后端 API 服务 +│ ├── src/ +│ │ ├── api/ +│ │ │ ├── routes/ +│ │ │ │ ├── check.ts # AML 检查 +│ │ │ │ ├── whitelist.ts # 白名单查询 +│ │ │ │ ├── appeal.ts # 申诉管理 +│ │ │ │ ├── health.ts # 健康检查 +│ │ │ │ ├── admin.ts # 管理后台路由 +│ │ │ │ ├── admin-auth.ts # 管理员认证 +│ │ │ │ ├── composer.ts # LI.FI Composer 集成 +│ │ │ │ ├── earn.ts # LI.FI Earn 数据代理 +│ │ │ │ ├── analytics.ts # LI.FI Analytics 集成 +│ │ │ │ └── behavior.ts # 行为分析端点 +│ │ │ └── middleware/ +│ │ │ ├── rate-limiter.ts +│ │ │ ├── validator.ts +│ │ │ └── logger.ts +│ │ ├── services/ +│ │ │ ├── risk-scorer.ts # 风险评分引擎 +│ │ │ ├── cache-service.ts # 分层缓存 +│ │ │ ├── circuit-breaker.ts # 熔断器 +│ │ │ ├── auth-service.ts # 认证服务 (JWT) +│ │ │ ├── analytics-service.ts # LI.FI Analytics 服务 +│ │ │ └── behavior-analyzer.ts # 行为分析引擎 +│ │ ├── data/ +│ │ │ └── risk-data-loader.ts # 本地数据预加载 +│ │ ├── db/ +│ │ │ └── prisma-client.ts +│ │ └── app.ts +│ ├── data/ # 风险数据文件(本地化) +│ │ ├── ofac-crypto-addresses.json +│ │ ├── hacker-addresses.json +│ │ ├── scam-addresses.json +│ │ ├── mixer-addresses.json +│ │ └── whitelist.json +│ ├── prisma/ +│ │ └── schema.prisma # 统一schema文件,支持SQLite开发和PostgreSQL生产 +│ ├── scripts/ # 【规划中,暂未实现】风险数据自动更新脚本 +│ │ ├── update-risk-data.sh +│ │ ├── parse-ofac.js +│ │ └── sync-lifi-whitelist.js +│ ├── Dockerfile +│ ├── package.json +│ └── tsconfig.json +│ +├── frontend-demo/ # Demo 前端(面向评审 / 集成商) +│ ├── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx +│ │ ├── pages/ +│ │ │ ├── CheckerPage.tsx # 地址风险检查主页 +│ │ │ ├── ComparePage.tsx # LI.FI 对比演示页 +│ │ │ └── EarnFlowPage.tsx # Earn 集成流程演示 +│ │ ├── components/ +│ │ │ ├── AddressInput.tsx +│ │ │ ├── RiskResultCard.tsx +│ │ │ ├── RiskScoreMeter.tsx +│ │ │ ├── FactorList.tsx +│ │ │ ├── LiveStats.tsx +│ │ │ ├── CodeSnippet.tsx +│ │ │ └── ComparePanel.tsx +│ │ ├── api/ +│ │ │ └── bridgeshield.ts # API 调用封装 +│ │ └── constants/ +│ │ └── demo-addresses.ts # 演示地址预设 +│ ├── public/ +│ ├── index.html +│ ├── vite.config.ts +│ ├── tailwind.config.ts +│ ├── package.json +│ └── .env.example +│ +├── frontend-admin/ # 后台管理前端(面向内部运营) +│ ├── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx +│ │ ├── pages/ +│ │ │ ├── DashboardPage.tsx # 风险仪表盘 +│ │ │ ├── AppealPage.tsx # 申诉管理 +│ │ │ ├── WhitelistPage.tsx # 白名单管理 +│ │ │ ├── LogsPage.tsx # 检查日志 +│ │ │ └── LoginPage.tsx # 管理员登录 +│ │ └── components/ +│ │ ├── StatsCard.tsx +│ │ ├── RiskChart.tsx +│ │ ├── AppealTable.tsx +│ │ └── WhitelistTable.tsx +│ ├── index.html +│ ├── vite.config.ts +│ ├── package.json +│ └── .env.example +│ +├── packages/ # 共享包 +│ └── sdk/ # @bridgeshield/sdk npm 包 +│ ├── src/ +│ │ ├── index.ts # 导出入口 +│ │ ├── client.ts # BridgeShieldClient 主类 +│ │ └── types.ts # TypeScript 类型定义 +│ ├── __tests__/ +│ │ └── client.test.ts # 单元测试 +│ ├── README.md # SDK 使用文档 +│ ├── package.json +│ ├── tsconfig.json +│ └── tsup.config.ts # esbuild 打包配置 +│ +├── docker-compose.yml # 一键启动全部服务 +├── docker-compose.dev.yml # 开发环境 +└── README.md # 根目录说明 +``` + +--- + +## 3. 系统架构 + +### 3.1 整体架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 外部调用方 │ +│ LI.FI SDK │ @bridgeshield/sdk │ 钱包 App │ Demo前端 │ Admin前端 │ +└──────────────────────────────┬──────────────────────────────────┘ + │ HTTP / HTTPS + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ API 网关层 │ +│ Rate Limiter (100/min/IP) │ API Key 验证 │ 请求日志 │ +└──────────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 风险引擎层 │ +│ │ +│ ┌────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ +│ │ 白名单名单检查 │──▶│ 风险数据查询 │──▶│ 风险评分计算 │ │ +│ │ (优先命中) │ │ (多源聚合) │ │ (加权规则引擎) │ │ +│ └────────────┘ └──────────────┘ └──────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 熔断器 (Opossum) │ │ +│ │ 超时 3s │ 错误率 >50% 触发 │ 30s 后自动恢复 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└───────────────────────┬──────────────────────────────────────────┘ + │ + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ +┌──────────────┐ ┌────────────┐ ┌─────────────────────────────────┐ +│ 内存缓存 │ │ 内存风险索引│ │ 外部数据源 │ +│ (分级 TTL) │ │ (O(1)查询) │ │ OFAC/UN/EU/Chainalysis │ +└──────────────┘ └────────────┘ └─────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 数据持久层 + LI.FI 集成 │ +│ SQLite(开发) / PostgreSQL(生产) │ +│ 风险记录 │ 白名单 │ 申诉记录 │ 检查日志 │ 审计日志 │ +│ │ +│ LI.FI Analytics API → 行为分析增强 → 跨链历史聚合 │ +│ LI.FI Earn Data API → Vault 风险检测 → 资产组合监控 │ +│ LI.FI Composer API → 风控门控 → 拦截/通过决策 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 请求处理流程(短路原则) + +``` +入参校验(地址格式、链 ID) + ↓ +命中内存缓存?→ 直接返回(< 1ms) + ↓ 未命中 +命中本地白名单?→ 返回 ALLOW,写缓存 + ↓ 未命中 +查询内存风险索引(本地 JSON 预加载,< 1ms) + ↓ +外部 API 补充查询(Chainalysis 等,熔断保护) + ↓ +LI.FI Analytics 查询(跨链历史,增强行为分析) + ↓ +计算综合风险评分(加权规则引擎 + 行为分析) + ↓ +写入内存缓存(按风险等级分级 TTL)+ 异步写 DB + ↓ +返回结果 +``` + +--- + +## 4. 黑名单数据来源与管理 + +### 4.1 数据源分层 + +#### 第一层:制裁名单(最高权威,免费) + +| 数据源 | 内容 | 更新频率 | 获取方式 | +|--------|------|----------|----------| +| OFAC SDN List | 美国制裁加密地址 | 每日 | 官网直接下载 XML | +| UN 制裁名单 | 联合国制裁 | 每周 | UN 官方 API | +| EU 制裁名单 | 欧盟制裁 | 每日 | EUR-Lex 数据库 | +| Chainalysis 制裁 API | 加密货币地址专项 | 实时 | 免费公开端点(有限额) | + +```bash +# OFAC 数据下载 +https://www.treasury.gov/ofac/downloads/sdn.xml +https://www.treasury.gov/ofac/downloads/sanctions/1.0/sdn_advanced.xml +``` + +#### 第二层:链上行为标记数据库 + +| 数据源 | 内容 | 成本 | MVP 用法 | +|--------|------|------|----------| +| Chainalysis KYT | 黑客、诈骗、混币器 | 付费 $1000+/月 | 免费制裁 API 代替 | +| CryptoScamDB | 开源诈骗地址库 | 免费开源 | GitHub 直接拉取 | +| DeFiLlama Hacks DB | 历史黑客攻击地址 | 免费 | GitHub / API | +| Forta Network | 实时链上威胁情报 | 免费社区版 | GraphQL API | +| SlowMist Hacked DB | 黑客事件数据库 | 免费 | https://hacked.slowmist.io | + +#### 第三层:手工整理(MVP 演示核心) + +手工收集知名黑客事件地址,写入 `hacker-addresses.json`,演示时保证命中: + +| 事件 | 地址(示例) | 涉案金额 | +|------|-------------|----------| +| Ronin Bridge 2022 | `0x098B716...` | $625M | +| Nomad Bridge 2022 | `0x9D4...` | $190M | +| Wormhole 2022 | `0x629e...` | $320M | +| Tornado Cash 合约 | `0xd90e2f9...` | 混币器 | + +### 4.2 本地数据预加载(MVP 核心——演示不依赖外部 API) + +```typescript +// backend/src/data/risk-data-loader.ts + +class RiskDataLoader { + private riskIndex = new Map(); + private whitelistIndex = new Set(); + + async initialize() { + const dataFiles = [ + { file: 'ofac-crypto-addresses.json', category: 'SANCTION', score: 95 }, + { file: 'hacker-addresses.json', category: 'HACKER', score: 90 }, + { file: 'mixer-addresses.json', category: 'MIXER', score: 80 }, + { file: 'scam-addresses.json', category: 'SCAM', score: 65 }, + ]; + + for (const { file, category, score } of dataFiles) { + const data = JSON.parse(fs.readFileSync(`./data/${file}`, 'utf8')); + data.forEach((entry: any) => { + this.riskIndex.set(entry.address.toLowerCase(), { + category, riskScore: score, + label: entry.label, + source: entry.source, + }); + }); + } + + const whitelist = JSON.parse(fs.readFileSync('./data/whitelist.json', 'utf8')); + whitelist.forEach((e: any) => this.whitelistIndex.add(e.address.toLowerCase())); + + console.log(`[RiskLoader] ${this.riskIndex.size} 风险地址 | ${this.whitelistIndex.size} 白名单地址`); + } + + // O(1) 查询,响应 < 1ms + lookup(address: string) { + const addr = address.toLowerCase(); + if (this.whitelistIndex.has(addr)) return { type: 'WHITELIST' }; + return this.riskIndex.get(addr) || null; + } +} +``` + +### 4.3 数据自动更新(每日定时) + +```bash +#!/bin/bash +# scripts/update-risk-data.sh + +# 1. 更新 OFAC 制裁名单 +curl -s "https://www.treasury.gov/ofac/downloads/sdn.xml" -o data/raw/ofac-sdn.xml +node scripts/parse-ofac.js data/raw/ofac-sdn.xml data/ofac-crypto-addresses.json + +# 2. 更新 CryptoScamDB +curl -s "https://raw.githubusercontent.com/CryptoScamDB/blacklist/master/data/urls.json" \ + -o data/raw/cryptoscamdb.json +node scripts/parse-scamdb.js data/raw/cryptoscamdb.json data/scam-addresses.json + +# 3. 同步 LI.FI 官方白名单(从 LI.FI 合约读取) +node scripts/sync-lifi-whitelist.js + +# 4. 热更新(无需重启服务) +curl -X POST http://localhost:3000/admin/reload-data +``` + +--- + +## 5. 地址扫描与资金溯源 + +### 5.1 扫描维度(并发处理) + +``` +检查对象 + ├── fromAddress(发送方) ← 最核心 + ├── toAddress(接收方) ← 次核心 + ├── 路由中间合约 ← 验证路径合法性 + └── 目标链接收地址 ← 跨链目标检查 +``` + +```typescript +// 并发扫描所有相关地址,取最高风险值 +async function scanAllAddresses(req: CheckRequest) { + const promises = [ + scanSingle(req.fromAddress, req.chainId, 'FROM'), + req.toAddress ? scanSingle(req.toAddress, req.toChainId, 'TO') : null, + ...(req.route?.steps || []).map(step => + scanSingle(step.estimate?.approvalAddress, step.action.fromChainId, 'ROUTER') + ) + ].filter(Boolean); + + const results = await Promise.allSettled(promises); + return aggregateByHighestRisk(results); +} +``` + +### 5.2 资金溯源与行为分析(LI.FI Analytics 增强) + +BridgeShield 通过 **LI.FI Analytics API** 获取跨链交易历史,结合本地检查日志实现行为分析。 + +```typescript +// backend/src/services/behavior-analyzer.ts +// 结合 LI.FI 历史和本地日志,生成行为画像 + +async function analyzeWalletBehavior(wallet: string, chainId: number) { + // 1. 获取 LI.FI 跨链交易历史(过去 90 天) + const lifiHistory = await lifiAnalytics.getTransfers(wallet, 90); + + // 2. 获取本地 BridgeShield 检查日志 + const localChecks = await getRecentChecks(wallet, 30); + + // 3. 分析跨链频率 + const crossChainCount = lifiHistory.length; + const uniqueChains = new Set(lifiHistory.map(t => t.toChainId)).size; + + // 4. 检测混币器/高风险地址交互 + const highRiskInteractions = lifiHistory.filter(tx => { + return tx.fromAddress.some(addr => riskDataLoader.lookup(addr)); + }); + + // 5. 检测金额异常 + const avgAmount = calculateAverage(lifiHistory.map(t => t.amountUSD)); + const amountSpike = lifiHistory.some(tx => tx.amountUSD > avgAmount * 10); + + return { + signals: { + highVelocity: crossChainCount > 10, + chainDiversity: uniqueChains, + amountSpike: amountSpike ? lifiHistory.find(tx => tx.amountUSD).amountUSD : null, + highRiskInteractions: highRiskInteractions.length, + }, + anomalyScore: calculateAnomalyScore(...), + }; +} +``` + +**溯源深度路线图:** + +| 版本 | 深度 | 实现方式 | +|------|------|----------| +| MVP | 历史聚合 | LI.FI Analytics API(90 天) | +| v1.0 | 实时监控 | WebSocket 订阅,实时更新 | +| v2.0 | 图分析 | 结合本地图数据库,全链路追溯 | + +### 5.3 混币器交互检测 + +```typescript +const MIXER_CONTRACTS = [ + '0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b', // Tornado 0.1 ETH + '0x910Cbd523D972eb0a6f4cAe4618aD62622b39DbF', // Tornado 1 ETH + '0xA160cdAB225685dA1d56aa342Ad8841c3b53f291', // Tornado 10 ETH + '0xD4B88Df4D29F5CedD6857912842cff3b20C8Cfa3', // Tornado 100 ETH +]; + +async function checkMixerInteraction(address: string, chainId: number) { + const txs = await getRecentTransactions(address, chainId); + return txs.some(tx => + MIXER_CONTRACTS.includes(tx.from?.toLowerCase()) || + MIXER_CONTRACTS.includes(tx.to?.toLowerCase()) + ); +} +``` + +--- + +## 6. 风险评分模型 + +### 6.1 评分体系 + +| 分数 | 风险等级 | 操作决策 | 说明 | +|------|----------|----------|------| +| 0 | NONE | ALLOW | 白名单地址,直接放行 | +| 1–30 | LOW | ALLOW | 无已知风险 | +| 31–69 | MEDIUM | REVIEW | 记录日志,进人工审核,**不拦截** | +| 70–100 | HIGH | BLOCK | 直接拦截,记录审计 | + +> **关键原则**:MEDIUM 不直接拦截,只记录——这是控制误判率的核心设计。 + +### 6.2 评分因子 + +#### A. 制裁与黑名单因子 + +| 因子 | 加分 | 数据来源 | +|------|------|----------| +| OFAC 制裁地址 | **+85** | OFAC SDN List | +| UN/EU 制裁地址 | **+80** | 官方制裁名单 | +| 已知黑客地址 | **+75** | DeFiLlama / SlowMist | +| 混币器合约本身 | **+70** | 手工整理 | +| 诈骗地址 | **+55** | CryptoScamDB | +| Chainalysis 高风险标记 | **+60** | Chainalysis API | + +#### B. 行为特征因子(链上分析) + +| 因子 | 加分 | 检测方式 | +|------|------|----------| +| 近期收到混币器出款 | **+35** | 一跳溯源 | +| 直接收款来自黑名单地址 | **+25** | 一跳溯源 | +| 24h 内跨链 > 5 次 | **+15** | 链上频率分析 | +| 单笔 > $100,000 | **+10** | 金额换算 | +| 地址年龄 < 7 天 + 金额 > $10,000 | **+15** | 地址年龄分析 | +| 与高风险地址发生跨链交易 | **+25** | LI.FI 历史交易数据分析 | +| 首次使用 LI.FI 即进行大额交易 | **+15** | LI.FI 交易历史分析 | +| 检测到跨链 tumbling 模式 | **+12** | LI.FI 多链交易行为分析 | +| 交易金额远超历史平均水平 | **+10** | LI.FI 历史交易数据分析 | +| 短时间内跨 ≥8 条不同链交易 | **+8** | LI.FI 多链交易行为分析 | + +#### C. 交易上下文因子 + +| 因子 | 加分 | 说明 | +|------|------|------| +| 接收方地址高风险 | **+40** | toAddress 命中黑名单 | +| 路由经过可疑桥接 | **+20** | 路径中有被黑桥合约 | +| 目标链为高风险链 | **+10** | 部分小链 | + +#### D. 白名单减分 + +| 因子 | 效果 | +|------|------| +| LI.FI 官方地址 | **直接归零** | +| 知名 DeFi 协议 | **-20(最低 0)** | +| 申诉审核通过 | **-30(最低 0)** | + +### 6.3 评分计算逻辑 + +```typescript +// backend/src/services/risk-scorer.ts + +class RiskScorer { + calculate(addressInfo: any, behaviorData: any, txContext: any) { + const factors: Factor[] = []; + let total = 0; + + // 制裁/黑名单因子 + const sanctionMap: Record = { + SANCTION: { score: 85, label: 'OFAC/UN/EU 制裁地址' }, + HACKER: { score: 75, label: `黑客攻击地址: ${addressInfo?.label}` }, + MIXER: { score: 70, label: '混币器合约地址' }, + SCAM: { score: 55, label: '诈骗地址' }, + }; + + if (addressInfo?.category) { + const m = sanctionMap[addressInfo.category]; + if (m) { factors.push({ name: m.label, score: m.score }); total += m.score; } + } + + // 行为因子 + if (behaviorData?.hasMixerInteraction) { + factors.push({ name: '近期与混币器有交互', score: 35 }); total += 35; + } + if (behaviorData?.hasHighRiskSender) { + factors.push({ name: '资金来自高风险地址', score: 25 }); total += 25; + } + + // 上下文因子 + if (txContext?.amountUSD > 100000) { + factors.push({ name: `大额交易 ($${Math.round(txContext.amountUSD / 1000)}K)`, score: 10 }); + total += 10; + } + + const riskScore = Math.min(100, Math.round(total)); + const { level, action } = this.getDecision(riskScore, addressInfo); + + return { riskScore, riskLevel: level, action, riskFactors: factors.map(f => f.name) }; + } + + private getDecision(score: number, info: any) { + // 制裁地址强制拦截 + if (info?.category === 'SANCTION') return { level: 'HIGH', action: 'BLOCK' }; + if (score >= 70) return { level: 'HIGH', action: 'BLOCK' }; + if (score >= 31) return { level: 'MEDIUM', action: 'REVIEW' }; + return { level: 'LOW', action: 'ALLOW' }; + } +} +``` + +### 6.4 评分示例 + +| 地址 | 命中因子 | 总分 | 决策 | +|------|----------|------|------| +| Ronin 黑客地址 | 黑客 +75,大额 +10 | 85 | **BLOCK** | +| Tornado 出款地址 | 混币器交互 +35,新地址 +15 | 50 | **REVIEW** | +| Uniswap V3 合约 | 命中白名单 → 归零 | 0 | **ALLOW** | +| LI.FI Diamond | LI.FI 官方 → 归零 | 0 | **ALLOW** | + +--- + +## 7. 核心 API 规格 + +### 7.1 POST /api/v1/aml/check + +**请求体:** +```json +{ + "address": "0x098B716B8Aaf21512996dC57EB0615e2383E2f96", + "chainId": 1, + "amount": "1000000000000000000", + "toAddress": "0xRecipientAddress", + "toChainId": 137, + "route": { + "steps": [ + { + "toolDetails": { "name": "Stargate" }, + "estimate": { "approvalAddress": "0x..." }, + "action": { "fromChainId": 1, "toChainId": 137 } + } + ] + } +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `address` | ✅ | 发送方地址(EVM 格式) | +| `chainId` | ✅ | 源链 Chain ID | +| `amount` | ❌ | 交易金额(wei),用于大额检测 | +| `toAddress` | ❌ | 接收方地址,提供则同时检查 | +| `route` | ❌ | LI.FI 路由信息,提供则检查路径 | + +**成功响应(200):** +```json +{ + "address": "0x098B716B8Aaf21512996dC57EB0615e2383E2f96", + "riskScore": 85, + "riskLevel": "HIGH", + "action": "BLOCK", + "riskFactors": ["黑客攻击地址: Ronin Bridge 2022", "大额历史交易"], + "recommendation": "建议拦截此交易并上报监管", + "cached": false, + "checkId": "chk_20250412_abc123", + "checkedAt": "2025-04-12T10:00:00.000Z", + "expiresAt": null, + "processingTimeMs": 43 +} +``` + +**降级响应(外部 API 不可用):** +```json +{ + "address": "0x...", + "riskScore": 0, + "riskLevel": "LOW", + "action": "ALLOW", + "fallback": true, + "fallbackReason": "Circuit breaker open, defaulting to ALLOW", + "processingTimeMs": 2 +} +``` + +### 7.2 GET /api/v1/aml/whitelist + +```json +{ + "total": 287, + "categories": { + "LIFI_OFFICIAL": 45, + "KNOWN_PROTOCOL": 180, + "BRIDGE_CONTRACT": 52, + "APPEAL_APPROVED": 10 + }, + "lastSyncedAt": "2025-04-12T00:00:00Z" +} +``` + +### 7.3 POST /api/v1/aml/appeal + +**请求体:** +```json +{ + "address": "0x...", + "chainId": 1, + "reason": "这是我个人冷钱包,从未与高风险地址交互", + "contact": "user@example.com" +} +``` + +**响应:** +```json +{ + "success": true, + "ticketId": "APT-20250412-001", + "status": "PENDING", + "estimatedReviewAt": "2025-04-13T10:00:00Z" +} +``` + +申诉提交后,地址自动加入**临时白名单 24 小时**,管理员在后台审批。 + +### 7.4 GET /api/v1/health + +```json +{ + "status": "healthy", + "timestamp": "2025-04-13T10:00:00.000Z", + "version": "0.0.0", + "uptime": "3600s", + "services": { + "database": { "healthy": true, "status": "connected" }, + "riskData": { "healthy": true, "status": "loaded", "riskDataCount": 15234, "whitelistCount": 25 }, + "cache": { "healthy": true, "status": "ready", "stats": { "keys": 1234, "hits": 5678, "misses": 432 } }, + "redis": "disabled (MVP - using in-memory cache)" + } +} +``` + +### 7.5 Admin 认证 API + +#### POST /api/v1/admin/auth/login +**功能**:管理员登录,验证用户名密码并返回 JWT token。 + +**请求体:** +```json +{ + "username": "admin", + "password": "secure_password" +} +``` + +**响应:** +```json +{ + "success": true, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": "user_abc123", + "username": "admin", + "role": "ADMIN" + }, + "expiresIn": 86400 +} +``` + +#### GET /api/v1/admin/auth/me +**功能**:获取当前登录的管理员信息(需要 JWT token)。 + +**请求头:** +``` +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +**响应:** +```json +{ + "success": true, + "user": { + "id": "user_abc123", + "username": "admin", + "role": "ADMIN", + "createdAt": "2025-04-12T10:00:00Z" + } +} +``` + +### 7.6 LI.FI 扩展 API + +#### GET /api/v1/analytics/transfers +**功能**:查询 LI.FI 跨链交易历史(人工调查端点),支持分页过滤,返回数据附带 BridgeShield 风险标记。 +**缓存**:15分钟内存缓存。 +**请求参数:** +```json +{ + "wallet": "0x1234567890abcdef1234567890abcdef12345678", + "limit": 20, + "offset": 0 +} +``` + +#### GET /api/v1/behavior/profile/:wallet +**功能**:生成 C 端钱包行为画像,返回异常风险信号(交易频率、链多样性、金额异动、决策漂移等)。 +**响应:** +```json +{ + "wallet": "0x1234...", + "signals": { + "highVelocity": true, + "chainDiversity": 8, + "amountSpike": 150000, + "decisionDrift": null, + "highRiskInteractions": 3 + }, + "anomalyScore": 65, + "riskLevel": "MEDIUM" +} +``` + +#### GET /api/v1/earn/vaults +**功能**:代理 LI.FI Earn Data API,获取可用 vault 列表,附带风险检测标记。 +**响应**:返回所有链上的 vault 列表,每个 vault 包含 BridgeShield 风险评分。 + +#### GET /api/v1/earn/vault/:network/:address +**功能**:代理 LI.FI Earn Data API,获取单个 vault 详情,验证合约地址风险。 +**路径参数:** +- `network`: 网络标识(如 "eth", "polygon", "bsc") +- `address`: vault 合约地址 + +#### GET /api/v1/earn/portfolio/:wallet +**功能**:代理 LI.FI Earn Data API,查询指定钱包的 Earn 持仓情况。 +**路径参数:** +- `wallet`: 钱包地址 + +#### GET /api/v1/composer/quote +**功能**:AML 风控门控的 LI.FI Composer 报价接口,风险等级为 HIGH 时拦截报价请求,返回 BLOCK 决策;REVIEW 级允许请求但记录日志。 +**依赖**:需要配置 `COMPOSER_API_KEY` 环境变量。 +**请求体:** +```json +{ + "fromChainId": 1, + "toChainId": 137, + "fromAddress": "0x1234...", + "toAddress": "0xabcd...", + "fromAmount": "1000000000000000000", + "fromToken": "0xUSDC", + "toToken": "0xUSDC" +} +``` +**响应(BLOCK 场景):** +```json +{ + "action": "BLOCK", + "riskScore": 85, + "riskLevel": "HIGH", + "reason": "fromAddress 带有制裁标记" +} +``` + +--- + +## 8. 数据库设计 + +### 8.1 环境选型 + +| 环境 | 数据库 | 原因 | +|------|--------|------| +| 开发 | SQLite | 零配置,文件存储,快速启动 | +| 生产 | PostgreSQL | 事务、并发、JSONB 支持 | + +使用 **Prisma ORM** 管理,两套 schema 文件对应两个环境。 + +### 8.2 核心数据模型(Prisma Schema) + +```prisma +// AddressRisk — 地址风险记录(缓存 + 持久化) +model AddressRisk { + id String @id @default(cuid()) + address String + chainId Int + riskScore Int + riskLevel String // LOW / MEDIUM / HIGH + action String // ALLOW / REVIEW / BLOCK + category String? // SANCTION / HACKER / MIXER / SCAM + source String + label String? + riskFactors String // JSON 数组 + cachedUntil DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@unique([address, chainId]) + @@index([riskLevel]) +} + +// WhitelistEntry — 白名单 +model WhitelistEntry { + id String @id @default(cuid()) + address String @unique + type String // LIFI_OFFICIAL / KNOWN_PROTOCOL / APPEAL_APPROVED + label String + chainId Int? + expiresAt DateTime? + createdBy String + createdAt DateTime @default(now()) +} + +// Appeal — 申诉记录 +model Appeal { + id String @id @default(cuid()) + ticketId String @unique + address String + reason String + contact String? + status String @default("PENDING") // PENDING / APPROVED / REJECTED + reviewNote String? + reviewedAt DateTime? + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@index([status]) +} + +// CheckLog — 检查日志(用于仪表盘统计) +model CheckLog { + id String @id @default(cuid()) + checkId String @unique + address String + chainId Int + riskScore Int + riskLevel String + action String + riskFactors String // JSON + processingTimeMs Int + cached Boolean @default(false) + fallback Boolean @default(false) + createdAt DateTime @default(now()) + @@index([riskLevel]) + @@index([createdAt]) +} + +// AuditLog — 操作审计 +model AuditLog { + id String @id @default(cuid()) + action String + targetId String? + operator String + before String? // JSON + after String? // JSON + createdAt DateTime @default(now()) +} +``` + +--- + +## 9. 缓存策略 + +### 9.1 分级 TTL + +```typescript +const CACHE_TTL = { + HIGH: 0, // 永久(制裁/黑客地址一旦确认不解除) + MEDIUM: 86400 * 7, // 7 天(可能误判,保留申诉时间) + LOW: 86400 * 3, // 3 天(定期刷新,避免遗漏新增风险) + WHITELIST: 86400 * 30, // 30 天 + FALLBACK: 300, // 5 分钟(降级结果不长期缓存) +}; + +const getCacheKey = (address: string, chainId: number) => + `bs:risk:${chainId}:${address.toLowerCase()}`; +``` + +### 9.2 单级内存缓存架构 + +```typescript +// 进程内内存缓存(< 1ms,热点地址) +// 实际使用 node-cache 实现,支持分级 TTL + +const cacheService = CacheService.getInstance(); + +async function checkWithCache(address: string, chainId: number) { + // 查询缓存 + const cached = cacheService.get(address, chainId); + if (cached) return { ...cached, cacheHit: true }; + + // 完整计算 + const result = await fullRiskCalculation(address, chainId); + + // 写入缓存(按风险等级分级 TTL) + cacheService.set(address, chainId, result, cacheTier); + return { ...result, cacheHit: false }; +} +``` + +--- + +## 10. 熔断与降级机制 + +**核心原则:BridgeShield 故障绝对不能导致 LI.FI 停服。** + +```typescript +// backend/src/services/circuit-breaker.ts +const CircuitBreaker = require('opossum'); + +function createBreaker(name: string, fn: Function) { + const breaker = new CircuitBreaker(fn, { + timeout: 3000, + errorThresholdPercentage: 50, + resetTimeout: 30000, + volumeThreshold: 10, + }); + + // 熔断时:放行 + 记录日志 + breaker.fallback(() => ({ + riskScore: 0, riskLevel: 'LOW', action: 'ALLOW', + fallback: true, fallbackReason: `${name} circuit open` + })); + + breaker.on('open', () => logger.error(`[CB] ${name} OPENED`)); + breaker.on('halfOpen', () => logger.warn(`[CB] ${name} HALF-OPEN`)); + breaker.on('close', () => logger.info(`[CB] ${name} CLOSED`)); + + return breaker; +} +``` + +**降级优先级:** + +``` +正常: 内存索引 + 内存缓存 + 外部 API +API 故障: 内存索引 + 内存缓存(跳过外部 API) +全部故障: ALLOW + fallback=true + 告警 +``` + +--- + +## 11. Demo 前端(独立目录) + +> **目录**:`frontend-demo/` +> **面向**:评审、外部集成商、LI.FI 团队 +> **工期**:4–6 小时 +> **目标**:90 秒内让评审理解 BridgeShield 的价值 + +### 11.1 技术栈 + +| 类别 | 选型 | +|------|------| +| 框架 | React 18 + TypeScript | +| 构建 | Vite | +| 样式 | TailwindCSS + CSS Variables | +| 路由 | React Router v6 | +| 请求 | TanStack Query v5 | +| 动画 | Framer Motion | + +### 11.2 目录结构 + +``` +frontend-demo/ +├── src/ +│ ├── main.tsx +│ ├── App.tsx # 路由配置 +│ │ +│ ├── pages/ +│ │ ├── CheckerPage.tsx # 主页:地址风险检查 +│ │ ├── ComparePage.tsx # 对比页:有无 AML 差异 +│ │ └── EarnFlowPage.tsx # Earn 集成流程演示 +│ │ +│ ├── components/ +│ │ ├── AddressInput.tsx # 地址输入 + 快捷预设 +│ │ ├── RiskResultCard.tsx # 风险结果卡片(核心) +│ │ ├── RiskScoreMeter.tsx # 0-100 分环形仪表盘 +│ │ ├── FactorList.tsx # 风险因子动态列表 +│ │ ├── LiveStats.tsx # 实时统计(检查数/拦截数) +│ │ ├── CodeSnippet.tsx # 3 行集成代码展示 +│ │ └── ComparePanel.tsx # 左右对比面板 +│ │ +│ ├── api/ +│ │ └── bridgeshield.ts # API 调用封装 + Mock 数据 +│ │ +│ └── constants/ +│ └── demo-addresses.ts # 演示地址预设 +│ +├── public/ +│ └── favicon.svg +├── index.html +├── vite.config.ts +├── tailwind.config.ts +├── package.json +└── .env.example +``` + +### 11.3 页面设计 + +#### 页面 1:地址风险检查(CheckerPage)路由 `/` + +**视觉风格**:深色背景(`#0A0E1A`)+ 科技蓝绿主色(`#00D4AA`)+ 危险红(`#FF3B3B`)。 +字体:标题 `JetBrains Mono`(等宽终端感),正文 `DM Sans`。 +风格定位:**链上安全审计终端**——专业、精准、有威慑感。 + +**布局图:** + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ ⬡ BridgeShield [Check] [Compare vs LI.FI ↗] │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ AML Gateway for LI.FI Cross-Chain Trades │ +│ ───────────────────────────────────────── │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 0x... [CHECK →] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ 快捷地址:[🔴 Ronin Hacker] [🟠 Tornado] [🟢 Lido] [🟢 Uniswap] │ +│ │ +├──────────────────────────────┬───────────────────────────────────┤ +│ │ │ +│ ┌────────────┐ │ 风险评分:85 / 100 │ +│ │ │ │ 风险等级:🔴 HIGH │ +│ │ 85 │ │ 操作决策:BLOCK │ +│ │ │ │ │ +│ └────────────┘ │ ── 风险因子 ────────────────── │ +│ 环形仪表盘 │ ✗ 黑客攻击地址 │ +│ │ Ronin Bridge 2022 · $625M │ +│ │ ✗ 与混币器有交互 │ +│ │ Tornado Cash · 3 笔 │ +│ │ │ +│ │ 响应时间:43ms 已缓存:否 │ +│ │ │ +├──────────────────────────────┴───────────────────────────────────┤ +│ ── 如果集成 BridgeShield,这笔交易会被 LI.FI 在入口拦截 ─────── │ +│ │ +│ lifi.hooks.beforeExecute.add(async (trade) => { │ +│ const risk = await bridgeshield.check(trade.fromAddress); │ +│ if (risk.action === 'BLOCK') throw new Error(...); │ +│ }); │ +│ │ +├──────────────────────────────────────────────────────────────────┤ +│ 今日检查:1,247 拦截:23 (1.8%) 平均响应:47ms 正常运行 │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**预设演示地址(`constants/demo-addresses.ts`):** + +```typescript +export const DEMO_ADDRESSES = [ + { + label: 'Ronin Hacker', + address: '0x098B716B8Aaf21512996dC57EB0615e2383E2f96', + description: 'Ronin Bridge 2022 · $625M', + expectedResult: 'BLOCK', + badge: 'HACKER', + }, + { + label: 'Tornado Cash', + address: '0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b', + description: 'Tornado Cash 合约 · 混币器', + expectedResult: 'BLOCK', + badge: 'MIXER', + }, + { + label: 'Lido stETH', + address: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + description: 'Lido Protocol · 知名协议', + expectedResult: 'ALLOW', + badge: 'SAFE', + }, + { + label: 'Uniswap V3', + address: '0x1F98431c8aD98523631AE4a59f267346ea31F984', + description: 'Uniswap V3 Factory · LI.FI 白名单', + expectedResult: 'ALLOW', + badge: 'WHITELIST', + }, +]; +``` + +**RiskResultCard 核心逻辑:** + +```typescript +// components/RiskResultCard.tsx +// 根据 action 动态变色:BLOCK=红,REVIEW=橙,ALLOW=绿 + +const actionConfig = { + BLOCK: { bg: 'bg-red-950', border: 'border-red-500', text: '🔴 BLOCK', desc: '此交易将被拦截' }, + REVIEW: { bg: 'bg-amber-950', border: 'border-amber-500', text: '🟠 REVIEW', desc: '进入人工审核队列' }, + ALLOW: { bg: 'bg-emerald-950', border: 'border-emerald-500', text: '🟢 ALLOW', desc: '交易正常放行' }, +}; +``` + +**动画设计(Framer Motion):** + +```typescript +// 结果出现时:从下方滑入 + 渐显 + + +// 评分数字:从 0 滚动到实际分值 +const [displayScore, setDisplayScore] = useState(0); +useEffect(() => { + const timer = setInterval(() => { + setDisplayScore(prev => Math.min(prev + 3, riskScore)); + }, 16); + return () => clearInterval(timer); +}, [riskScore]); + +// 风险因子:逐条出现(staggered) +factors.map((factor, i) => ( + +)) +``` + +#### 页面 2:LI.FI 对比演示(ComparePage)路由 `/compare` + +**核心设计理念**:左右分屏,左侧"没有 BridgeShield",右侧"有 BridgeShield",同一个黑客地址,结果截然不同。 + +``` +┌────────────────────────────────────────────────────────────────┐ +│ ⬡ BridgeShield [← Back to Check] │ +├──────────────────────┬─────────────────────────────────────────┤ +│ │ │ +│ ❌ WITHOUT │ ✅ WITH BridgeShield │ +│ BridgeShield │ │ +│ │ │ +│ ┌──────────────┐ │ ┌──────────────────────────────────┐ │ +│ │ Address: │ │ │ Address: │ │ +│ │ 0x098B71... │ │ │ 0x098B71... (Ronin Hacker) │ │ +│ │ │ │ │ │ │ +│ │ Chain: ETH │ │ │ ┌──────────────────────────────┐ │ │ +│ │ Amount: 1ETH │ │ │ │ 🔴 BLOCKED │ │ │ +│ │ │ │ │ │ Risk Score: 85/100 │ │ │ +│ │ [Execute →] │ │ │ │ Ronin Bridge Hacker 2022 │ │ │ +│ │ │ │ │ └──────────────────────────────┘ │ │ +│ │ ✅ SUCCESS │ │ │ │ │ +│ │ TX Hash: │ │ │ Transaction REJECTED before │ │ +│ │ 0xabc... │ │ │ reaching LI.FI network │ │ +│ │ │ │ │ Latency added: 43ms │ │ +│ │ 🚨 黑客资金 │ │ └──────────────────────────────────┘ │ +│ │ 已跨链转移 │ │ │ +│ └──────────────┘ │ │ +│ │ │ +├──────────────────────┴─────────────────────────────────────────┤ +│ 同一笔交易,差异只在于 3 行代码 │ +│ │ +│ lifi.hooks.beforeExecute.add(async (trade) => { │ +│ const r = await bridgeshield.check(trade.fromAddress); │ +│ if (r.action === 'BLOCK') throw new Error(r.riskFactors); │ +│ }); │ +└────────────────────────────────────────────────────────────────┘ +``` + +**左侧模拟"LI.FI 无 AML"**:展示交易执行成功(模拟状态,不真实执行),显示"🚨 高风险资金已跨链转移"。 +**右侧调用真实 BridgeShield API**:显示真实检查结果,BLOCK 状态。 +两侧同步输入同一个地址,结果实时对比更新。 + +#### 页面 3:Earn 集成流程演示(EarnFlowPage)路由 `/earn-flow` + +**核心设计理念**:展示如何在 Earn 跨链收益聚合场景中集成 BridgeShield,确保 vault 合约安全。 + +``` +┌────────────────────────────────────────────────────────────────┐ +│ ⬡ BridgeShield Earn Integration [← Back] │ +├────────────────────────────────────────────────────────────────┤ +│ │ +│ 选择目标链:[ETH] [Polygon] [BSC] [Arbitrum] │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 推荐 Vaults(带风险标记) │ │ +│ │ ───────────────────────────────────────────────────── │ │ +│ │ ┌────────────────────────────────────────────────────┐ │ │ +│ │ │ 🟢 Aave V3 (ETH) APY: 3.2% TVL: $1.2B │ │ │ +│ │ │ Risk Score: 0 | ALLOW | 审计通过 │ │ │ +│ │ └────────────────────────────────────────────────────┘ │ │ +│ │ ┌────────────────────────────────────────────────────┐ │ │ +│ │ │ 🟠 Yearn V2 (ETH) APY: 4.5% TVL: $800M │ │ │ +│ │ │ Risk Score: 35 | REVIEW | 近期高风险地址交互 │ │ │ +│ │ └────────────────────────────────────────────────────┘ │ │ +│ │ ┌────────────────────────────────────────────────────┐ │ │ +│ │ │ 🔴 Mixer Vault (ETH) APY: 8.2% TVL: $50M │ │ │ +│ │ │ Risk Score: 75 | BLOCK | 检测到混币器合约地址 │ │ │ +│ │ └────────────────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ 连接钱包:[0x1234...5678] [Connect] │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 当前持仓(带 AML 预检) │ │ +│ │ ───────────────────────────────────────────────────── │ │ +│ │ • Aave V3 (ETH): 50,000 USDC 🟢 安全 │ │ +│ │ • Lido stETH (ETH): 2.5 ETH 🟢 安全 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ 集成代码示例: │ +│ ─────────────────────────────────────────────────────────────── │ +│ // 检查 Vault 合约风险 │ +│ const vaultRisk = await bridgeshield.checkVault({ │ +│ address: '0xAaveV3Address', │ +│ network: 'eth' │ +│ }); │ +│ │ +│ if (vaultRisk.action === 'BLOCK') { │ +│ console.warn('Vault is unsafe:', vaultRisk.riskFactors); │ +│ return; │ +│ } │ +└────────────────────────────────────────────────────────────────┘ +``` + +**核心功能:** +- **Vault 风险预检**:在用户存入资金前,先检查 vault 合约地址风险 +- **实时持仓监控**:展示用户当前持仓,每个地址都经过 AML 预检 +- **风险可视化**:用颜色标记不同风险等级的 vault +- **集成代码展示**:展示如何在 Earn 集成中使用 BridgeShield + +### 11.4 API 封装(含 Mock 降级) + +```typescript +// frontend-demo/src/api/bridgeshield.ts + +const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'; + +// Mock 数据(API 不可用时直接返回,演示零风险) +const MOCK_RESULTS: Record = { + '0x098b716b8aaf21512996dc57eb0615e2383e2f96': { + riskScore: 85, riskLevel: 'HIGH', action: 'BLOCK', + riskFactors: ['黑客攻击地址: Ronin Bridge 2022', '涉案金额 $625M'], + processingTimeMs: 43, cached: false, + }, + '0xd90e2f925da726b50c4ed8d0fb90ad053324f31b': { + riskScore: 70, riskLevel: 'HIGH', action: 'BLOCK', + riskFactors: ['混币器合约地址: Tornado Cash'], + processingTimeMs: 12, cached: true, + }, + '0xae7ab96520de3a18e5e111b5eaab095312d7fe84': { + riskScore: 0, riskLevel: 'LOW', action: 'ALLOW', + riskFactors: [], + processingTimeMs: 8, cached: true, + }, +}; + +export async function checkAddress(address: string, chainId = 1): Promise { + try { + const res = await fetch(`${API_BASE}/api/v1/aml/check`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address, chainId }), + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error('API error'); + return res.json(); + } catch { + // 降级到 Mock 数据,演示不中断 + const mock = MOCK_RESULTS[address.toLowerCase()]; + if (mock) return { ...mock, cached: true }; + return { riskScore: 0, riskLevel: 'LOW', action: 'ALLOW', riskFactors: [], processingTimeMs: 1 }; + } +} +``` + +### 11.5 环境配置 + +```bash +# frontend-demo/.env.example +VITE_API_BASE_URL=http://localhost:3000 + +# 生产部署 +# VITE_API_BASE_URL=https://api.bridgeshield.io +``` + +### 11.6 启动方式 + +```bash +# 开发 +cd frontend-demo +npm install +npm run dev # http://localhost:5173 + +# 生产构建 +npm run build # 产物到 dist/,可部署到任意静态托管 +``` + +--- + +## 12. 后台管理前端(独立目录) + +> **目录**:`frontend-admin/` +> **面向**:内部运营人员 +> **工期**:黑客松可选做(P1) +> **风格**:实用数据密度型,与 Demo 完全不同 + +### 12.1 技术栈 + +与 Demo 前端相同(React + Vite + TypeScript + Tailwind),但风格偏向数据管理工具:浅色背景、表格密度高、操作按钮多。 + +### 12.2 目录结构 + +``` +frontend-admin/ +├── src/ +│ ├── main.tsx +│ ├── App.tsx +│ │ +│ ├── pages/ +│ │ ├── LoginPage.tsx # 管理员登录 +│ │ ├── DashboardPage.tsx # 风险仪表盘 +│ │ ├── AppealPage.tsx # 申诉管理(核心操作) +│ │ ├── WhitelistPage.tsx # 白名单管理 +│ │ └── LogsPage.tsx # 检查日志查询 +│ │ +│ └── components/ +│ ├── StatsCard.tsx # 关键指标卡片 +│ ├── RiskTrendChart.tsx # 风险趋势图(Recharts) +│ ├── RiskDistributionPie.tsx # 风险分布饼图 +│ ├── AppealTable.tsx # 申诉列表 + 审批操作 +│ └── WhitelistTable.tsx # 白名单列表 + 增删 +│ +├── index.html +├── vite.config.ts +├── package.json +└── .env.example +``` + +### 12.3 各页面说明 + +#### LoginPage — 管理员登录 + +``` +┌──────────────────────────────────────────────────────┐ +│ │ +│ ⬡ BridgeShield Admin │ +│ │ +│ ┌────────────────┐ │ +│ │ │ │ +│ │ BridgeShield │ │ +│ │ Logo │ │ +│ │ │ │ +│ └────────────────┘ │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ Username │ │ +│ │ ─────────────────────────│ │ +│ │ admin │ │ +│ └──────────────────────────┘ │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ Password │ │ +│ │ ─────────────────────────│ │ +│ │ ••••••••••••• │ │ +│ └──────────────────────────┘ │ +│ │ +│ [ Login ] │ +│ │ +└──────────────────────────────────────────────────────┘ +``` + +**认证流程:** +1. 用户输入用户名密码 +2. 前端调用 `POST /api/v1/admin/auth/login` +3. 后端验证凭据,返回 JWT token +4. 前端存储 token(localStorage) +5. 后续请求携带 `Authorization: Bearer {token}` +6. 失败时显示错误提示 + +#### DashboardPage — 风险仪表盘 + +``` +┌──────────────────────────────────────────────────────┐ +│ BridgeShield Admin 今日 / 7天 / 本月 │ +├────────────┬────────────┬────────────┬───────────────┤ +│ 今日检查 │ 今日拦截 │ 缓存命中率 │ 平均响应时间 │ +│ 1,247 │ 23 │ 78% │ 47ms │ +│ ↑12% │ ↑5% │ ─ │ ─ │ +├────────────┴────────────┴────────────┴───────────────┤ +│ │ +│ 拦截趋势(折线图,过去 7 天) │ +│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ +│ │ +├───────────────────────┬───────────────────────────────┤ +│ 风险等级分布 │ 风险来源分类 │ +│ 饼图:LOW/MED/HIGH │ 饼图:SANCTION/HACKER/MIXER │ +└───────────────────────┴───────────────────────────────┘ +``` + +**图表库**:Recharts(轻量,已包含在 React 生态) + +```typescript +import { LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, Tooltip } from 'recharts'; +``` + +#### AppealPage — 申诉管理 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ 申诉管理 [待处理: 3] [全部] [待处理] │ +├─────────────┬──────────┬──────────┬──────────────┬──────────────┤ +│ Ticket ID │ 地址 │ 提交时间 │ 状态 │ 操作 │ +├─────────────┼──────────┼──────────┼──────────────┼──────────────┤ +│ APT-001 │ 0xabc... │ 4月12日 │ 🟡 PENDING │ [通过][驳回] │ +│ APT-002 │ 0xdef... │ 4月11日 │ 🟢 APPROVED │ [查看] │ +│ APT-003 │ 0x123... │ 4月10日 │ 🔴 REJECTED │ [查看] │ +└─────────────┴──────────┴──────────┴──────────────┴──────────────┘ +``` + +点击行展开查看申诉原因,"通过"按钮调用后端将地址加入白名单,"驳回"恢复原风险等级。 + +#### WhitelistPage — 白名单管理 + +``` +┌────────────────────────────────────────────────────────────────┐ +│ 白名单管理 [+ 添加地址] │ +├────────────────────────────────────────────────────────────── │ +│ 搜索地址... │ +├────────────────┬────────────────┬────────────┬────────────────┤ +│ 地址 │ 类型 │ 标签 │ 操作 │ +├────────────────┼────────────────┼────────────┼────────────────┤ +│ 0x1231DE... │ LIFI_OFFICIAL │ Diamond │ [移除] │ +│ 0x1F9843... │ KNOWN_PROTOCOL │ Uniswap V3 │ [移除] │ +└────────────────┴────────────────┴────────────┴────────────────┘ +``` + +### 12.4 后台管理启动 + +```bash +cd frontend-admin +npm install +npm run dev # http://localhost:5174 +``` + +--- + +## 13. LI.FI 集成方案 + +### 13.1 最简集成(3 行代码) + +```typescript +// 在 LI.FI SDK 调用链中加入 beforeExecute 钩子 +lifi.hooks.beforeExecute.add(async (execution) => { + const risk = await fetch('https://api.bridgeshield.io/api/v1/aml/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: execution.fromAddress, chainId: execution.fromChainId }) + }).then(r => r.json()); + + if (risk.action === 'BLOCK') throw new Error(`AML: ${risk.riskFactors.join(', ')}`); + // REVIEW: 记录日志,不阻断 +}); +``` + +### 13.2 LI.FI SDK PR 集成(提交到官方仓库) + +```typescript +// @lifi/sdk/src/aml/bridgeshield-provider.ts + +export class BridgeShieldAMLProvider { + constructor(private options: { apiUrl?: string; apiKey?: string; timeout?: number } = {}) {} + + async check(params: { address: string; chainId: number; amount?: string; route?: any }) { + const controller = new AbortController(); + const tid = setTimeout(() => controller.abort(), this.options.timeout || 5000); + try { + const res = await fetch(`${this.options.apiUrl || 'https://api.bridgeshield.io'}/api/v1/aml/check`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(this.options.apiKey ? { 'X-API-Key': this.options.apiKey } : {}) }, + body: JSON.stringify(params), + signal: controller.signal, + }); + if (!res.ok) return this.fallback('API error'); + return res.json(); + } catch (e) { + return this.fallback(String(e)); + } finally { + clearTimeout(tid); + } + } + + private fallback(reason: string) { + console.warn(`[BridgeShield] Fallback ALLOW: ${reason}`); + return { riskScore: 0, riskLevel: 'LOW', action: 'ALLOW', fallback: true }; + } +} +``` + +--- + +## 14. 部署架构 + +### 14.1 MVP 部署(Docker Compose 一键启动) + +```yaml +# docker-compose.yml(根目录) +version: '3.8' + +services: + # 后端 API + backend: + build: ./backend + ports: + - "3000:3000" + environment: + - NODE_ENV=development + - DATABASE_URL=file:./dev.db + - LOG_LEVEL=info + - JWT_SECRET=bridgeshield-docker-jwt-secret-change-me + - ADMIN_INIT_USERNAME=admin + - ADMIN_INIT_PASSWORD=admin-password + - DEMO_API_KEY=bridgeshield-demo-key + volumes: + - ./backend/data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # Demo 前端(Nginx 静态托管构建产物) + frontend-demo: + build: ./frontend-demo + ports: + - "5173:80" + restart: unless-stopped + depends_on: + - backend + restart: unless-stopped + depends_on: + - backend + + # 后台管理前端 + frontend-admin: + build: ./frontend-admin + ports: + - "5174:80" + restart: unless-stopped + depends_on: + - backend +``` + +**一键启动:** +```bash +git clone https://github.com/your-team/bridgeshield +cd bridgeshield +docker-compose up -d + +# 访问地址: +# Demo: http://localhost:5173 +# Admin: http://localhost:5174 +# API: http://localhost:3000 +``` + +**Render.com 一键部署:** +```markdown +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/your-team/bridgeshield) +``` + +### 14.2 各服务 Dockerfile + +**后端 Dockerfile(`backend/Dockerfile`):** +```dockerfile +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production +COPY . . +RUN npm run build +EXPOSE 3000 +CMD ["node", "dist/app.js"] +``` + +**前端 Dockerfile(`frontend-demo/Dockerfile`,admin 同):** +```dockerfile +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +``` + +### 14.3 环境变量汇总 + +**后端(`backend/.env`):** +```bash +NODE_ENV=development +PORT=3000 +DATABASE_URL=file:./dev.db # 开发 +# DATABASE_URL=postgresql://... # 生产 +LOG_LEVEL=info +JWT_SECRET=bridgeshield-jwt-secret-change-me +ADMIN_INIT_USERNAME=admin +ADMIN_INIT_PASSWORD=admin-password +DEMO_API_KEY=bridgeshield-demo-key +# LI.FI API 配置 +LI_FI_API_BASE_URL=https://li.quest +COMPOSER_API_KEY= # 可选,用于 Composer 报价 +# 行为分析阈值 +BEHAVIOR_THRESHOLD_MEDIUM=30 +BEHAVIOR_THRESHOLD_HIGH=60 +``` + +**Demo 前端(`frontend-demo/.env`):** +```bash +VITE_API_BASE_URL=http://localhost:3000 +``` + +**Admin 前端(`frontend-admin/.env`):** +```bash +VITE_API_BASE_URL=http://localhost:3000 +VITE_ADMIN_TOKEN= # 简单 token 认证(MVP) +``` + +--- + +## 15. 监控与告警 + +### 15.1 关键指标(Prometheus) + +```typescript +// 检查总数(按 action 分类) +const checkTotal = new Counter({ + name: 'bs_checks_total', + labelNames: ['action', 'risk_level', 'cached'], +}); + +// 响应时间分布 +const checkDuration = new Histogram({ + name: 'bs_check_duration_ms', + buckets: [5, 10, 25, 50, 100, 200, 500], +}); + +// 缓存命中 +const cacheHits = new Counter({ + name: 'bs_cache_hits_total', + labelNames: ['layer'], // L1 / L2 +}); + +// 熔断器状态 +const circuitState = new Gauge({ + name: 'bs_circuit_breaker_state', + labelNames: ['provider'], // 0=closed 1=open 2=half-open +}); +``` + +### 15.2 告警规则 + +| 告警 | 触发条件 | 严重度 | +|------|----------|--------| +| 高错误率 | 5 分钟错误率 > 5% | 🔴 Critical | +| 高延迟 | P95 > 500ms | 🟠 Warning | +| 熔断器开启 | 任意 CB 进入 OPEN | 🟠 Warning | +| 大量拦截 | 1 分钟 BLOCK > 100 | 🟠 Warning(可能误判风暴) | + +### 15.3 日志规范 + +```typescript +// 结构化 JSON 日志(所有请求) +logger.info('aml_check', { + checkId: 'chk_abc123', + address: '0x...', + chainId: 1, + riskScore: 85, + action: 'BLOCK', + cached: false, + processingTimeMs: 43, + fallback: false, +}); +``` + +--- + +## 16. 开发计划 + +### 16.1 技术栈汇总 + +| 类别 | 后端 | Demo 前端 | Admin 前端 | +|------|------|-----------|------------| +| 语言 | TypeScript | TypeScript | TypeScript | +| 框架 | Node.js + Express | React 18 + Vite | React 18 + Vite | +| 样式 | — | TailwindCSS | TailwindCSS | +| ORM | Prisma | — | — | +| 缓存 | node-cache | — | — | +| 熔断 | opossum | — | — | +| 图表 | — | — | Recharts | +| 动画 | — | Framer Motion | — | + +### 16.2 3 天黑客松执行计划 + +#### Day 1(上午):项目初始化 + 核心后端 + +| 任务 | 产出 | 耗时 | +|------|------|------| +| 初始化三个目录,配置 TypeScript + Vite | 可运行空项目 | 1h | +| 准备本地风险数据文件(手工整理 500+ 地址) | `data/*.json` | 1h | +| 实现 RiskDataLoader(内存预加载) | O(1) 查询 | 1h | +| 实现 RiskScorer(加权评分引擎) | 核心评分逻辑 | 1h | + +#### Day 1(下午):后端 API + Demo 前端骨架 + +| 任务 | 产出 | 耗时 | +|------|------|------| +| 实现 `/aml/check`、`/whitelist`、`/appeal` | 3 个 API 可调用 | 2h | +| 内存缓存(node-cache) + Opossum 熔断器 | 生产级可靠性 | 1h | +| Demo 前端骨架 + 路由 + 地址输入组件 | 基本页面框架 | 1h | + +#### Day 2(上午):Demo 前端核心功能 + +| 任务 | 产出 | 耗时 | +|------|------|------| +| RiskResultCard + RiskScoreMeter(含动画) | 核心展示组件 | 2h | +| 预设演示地址 + API Mock 降级 | 演示零风险 | 1h | +| LiveStats 实时统计展示 | 数据面板 | 1h | + +#### Day 2(下午):对比页 + 集成代码 + Admin + +| 任务 | 产出 | 耗时 | +|------|------|------| +| ComparePage 左右对比面板 | 对比演示页完成 | 2h | +| CodeSnippet 3 行集成代码展示 | 增强说服力 | 30min | +| Admin 仪表盘 + 申诉管理(基础版) | P1 功能 | 1.5h | + +#### Day 3:打磨 + 准备演示 + +| 任务 | 产出 | 耗时 | +|------|------|------| +| 全链路联调(前端 → API → 返回结果) | 端到端通 | 1h | +| UI 细节打磨(动画、响应式、边界情况) | 精致的 Demo | 1h | +| README(根目录 + 各子目录)+ 一键部署 | 文档完整 | 1h | +| 演示排练(5 遍以上,精确计时) | 熟练演示 | 2h | +| 录制备用演示视频 | 备用方案 | 30min | + +### 16.3 演示流程(3 分钟精确脚本) + +| 时间 | 动作 | 话术 | +|------|------|------| +| 0:00–0:20 | 打开 Demo,输入 Ronin 黑客地址 | "这是 2022 年 Ronin 黑客的地址,偷走了 $625M,现在他想通过 LI.FI 跨链转移资金。" | +| 0:20–0:40 | 点击 CHECK,结果出现(BLOCK,85 分) | "BridgeShield 在 43 毫秒内识别并拦截了这笔交易,用户甚至感知不到延迟。" | +| 0:40–1:00 | 切到对比页,左侧"无 AML 直接成功",右侧 BLOCK | "这就是有没有 BridgeShield 的区别。左边资金已经跑路,右边在入口就被拦了。" | +| 1:00–1:20 | 输入 Lido 地址,结果 ALLOW | "正常用户完全不受影响,延迟不到 50ms,体验零损耗。" | +| 1:20–1:40 | 展示 CodeSnippet(3 行代码) | "LI.FI 只需要加这 3 行代码,今天就能上线。我们已经准备好了 PR。" | +| 1:40–2:00 | 展示 LiveStats(今日检查 1247 笔,拦截 23 笔) | "这是真实数据——1.8% 的跨链交易来自高风险地址,平均每天 23 笔,每年为 LI.FI 避免数百万美元的监管风险。" | +| 2:00–2:20 | 展示一键部署按钮 | "点这个按钮,5 分钟内部署到 LI.FI 自己的服务器,数据完全自主可控。" | +| 2:20–2:30 | 收尾 | "BridgeShield 不是概念验证,是今天就能用的生产级系统。谢谢。" | + +### 16.4 演示预设地址(确保命中) + +| 地址 | 类型 | 预期结果 | 演示用途 | +|------|------|----------|----------| +| `0x098B716B8Aaf21512996dC57EB0615e2383E2f96` | Ronin Hacker | BLOCK 85 分 | 主演示拦截 | +| `0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b` | Tornado Cash | BLOCK 70 分 | 混币器演示 | +| `0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84` | Lido stETH | ALLOW 0 分 | 正常用户演示 | +| `0x1F98431c8aD98523631AE4a59f267346ea31F984` | Uniswap V3 | ALLOW 0 分 | 白名单演示 | + +--- + +## 附录:数据文件格式 + +### hacker-addresses.json +```json +[ + { + "address": "0x098B716B8Aaf21512996dC57EB0615e2383E2f96", + "label": "Ronin Bridge Hacker 2022", + "category": "HACKER", + "source": "SlowMist", + "amountUSD": 625000000, + "chains": [1, 2020], + "date": "2022-03-29" + } +] +``` + +### whitelist.json +```json +[ + { + "address": "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE", + "type": "LIFI_OFFICIAL", + "label": "LI.FI Diamond Contract", + "chainId": null + }, + { + "address": "0x1F98431c8aD98523631AE4a59f267346ea31F984", + "type": "KNOWN_PROTOCOL", + "label": "Uniswap V3 Factory", + "chainId": 1 + } +] +``` + +--- + +*文档版本:v2.0 | 创建:2025-04-12 | 状态:完整工程执行版(含 Demo + Admin 前端)* diff --git a/docs/dev_docs.md b/docs/dev_docs.md index 43def8c..327eab1 100644 --- a/docs/dev_docs.md +++ b/docs/dev_docs.md @@ -1,7 +1,7 @@ -# BridgeShield v2.0 完整工程设计文档 +# BridgeShield v0.0.0 完整工程设计文档 > **项目**:BridgeShield — LI.FI 跨链交易 AML 入口检查系统 -> **版本**:v2.0 +> **版本**:v0.0.0 > **文档状态**:工程执行版(含 Demo 前端) > **目标读者**:开发团队、技术评审 diff --git a/docs/dev_log.md b/docs/dev_log.md index 79aa7c7..b7f3b4b 100644 --- a/docs/dev_log.md +++ b/docs/dev_log.md @@ -2,6 +2,134 @@ --- +## 2026-04-13 — v0.0.7 CI 修复 + SDK 类型补全 + 前端单元测试 + +### 概述 +修复 CI 前端测试失败问题(前端项目无测试文件导致 `vitest` 报错),补全 SDK 类型定义以匹配后端完整响应字段,新增前端单元测试。 + +### 变更内容 + +**CI 配置修复 (`.github/workflows/ci.yml`)** +- Frontend Demo 和 Frontend Admin 的 `npm test` 步骤添加条件检查 +- 使用 `ls src/**/*.test.{ts,tsx}` 检查项目内测试文件(避免匹配 node_modules) +- 测试文件不存在时跳过测试而非报错退出 + +**SDK 类型补全 (`packages/sdk/src/types.ts`)** +- `CheckAddressResponse` 新增完整行为分析字段: + - `behavior?: BehaviorProfile` — 行为画像对象 + - `behaviorEscalated?: boolean` — 行为分析是否升级风险 + - `behaviorReason?: string` — 行为分析升级原因 + - `baseDecision?: Action` — 行为调整前的原始决策 +- 新增 `BehaviorSignal` 和 `BehaviorProfile` 接口定义: + - `signals`, `lifiSignals` 风险信号数组 + - `metrics` 包含 velocity、chainNovelty、amountSpike、decisionDrift、lifiHistoryFallback 等指标 + - LI.FI 增强字段:`lifiScore`、`lifiConfidence`、`lifiHistory`、`lifiCrossChainTumbling` 等 + +**SDK 与后端兼容性验证** +- 后端 `check.ts` 返回字段全部已在 SDK 类型中覆盖 +- 认证方式兼容:后端接受 `Authorization: Bearer ` 和 `X-API-Key` 两种格式 + +**Frontend Demo 单元测试 (`frontend-demo/src/__tests__/api/bridgeshield.test.ts`)** +- 19 个测试用例覆盖 API 函数: + - `transformCheckResult()` — 后端响应转换逻辑 + - `buildQueryString()` — 查询字符串构建 + - `readErrorMessage()` — 错误消息提取 + - `isRecord()` — 类型守卫函数 + +**Frontend Admin 单元测试** +- `frontend-admin/src/__tests__/api/admin-api.test.ts` — 10 个测试用例 + - `normalizeAppeal()` — 申诉数据归一化 + - `normalizeWhitelistEntry()` — 白名单条目归一化 + - `buildTransferQueryString()` — 转账查询字符串构建 +- `frontend-admin/src/__tests__/auth/session.test.ts` — 9 个测试用例 + - Session 管理函数:`getAdminAccessToken`, `getAdminUser`, `saveAdminSession`, `clearAdminSession`, `isAdminAuthenticated` + +### 构建验证 +| 子项目 | `npm run build` | `npm test` | +|--------|-----------------|------------| +| Backend | ✅ 通过 | ✅ 138/138 | +| Frontend Demo | ✅ 通过 | ✅ 19/19 | +| Frontend Admin | ✅ 通过 | ✅ 19/19 | +| SDK | ✅ 通过 | ✅ 21/21 | + +--- + +## 2026-04-12 — v0.0.6 LI.FI Analytics 交易历史集成 + +### 概述 +LI.FI Analytics API 集成增强 AML 风控:合并重复 API URL,交易历史流入 behavior-analyzer,新增 LI.FI 风险信号检测。 + +### 变更内容 + +**Backend — API URL 合并** +- 合并 `COMPOSER_API_BASE_URL` + `ANALYTICS_API_BASE_URL` → `LI_FI_API_BASE_URL` +- `.env.example` / `.env` 更新为 `LI_FI_API_BASE_URL=https://li.quest` +- `composer.ts` / `analytics-service.ts` 使用统一环境变量 +- 向后兼容:fallback 到旧环境变量 + +**Backend — Analytics 路由 (`src/api/routes/analytics.ts`)** +- `GET /api/v1/analytics/transfers` — 代理 LI.FI Analytics API +- 支持分页(cursor-based)、过滤(status, fromChain, toChain, fromTime, toTime) +- 返回 `_bridgeShield` 元数据标记 +- 作为调查端点保留(供人工审核使用) + +**Backend — Analytics Service (`src/services/analytics-service.ts`)** +- `fetchTransfers()` — 获取 LI.FI transfers 数据 +- `transformResponse()` — 转换为内部格式 +- `queryLocalTransfers()` — 本地 checkLog fallback +- `getRateLimitInfo()` — 限流信息 +- 15 分钟 TTL 缓存 + +**Backend — Behavior Analyzer 增强 (`src/services/behavior-analyzer.ts`)** +- `calculateProfile()` 新增 `lifiHistory` 参数 +- **LI.FI 风险信号检测**: + - +25 分:高风险地址交互(与黑客/混币器地址交易) + - +15 分:首次 LI.FI 交易为高价值 + - +12 分:跨链 tumblng 模式检测 + - +10 分:金额 spike vs LI.FI 历史平均 + - +8 分:高链多样性(≥8 条链) +- `lifiSignals` 数组:记录 LI.FI 触发的风险信号 +- `metrics.lifiHistoryFallback`:LI.FI 数据不可用时标记 +- **置信度提升**:有 LI.FI 数据时 confidence 提升到 HIGH + +**Backend — 验证器 (`src/api/middleware/validator.ts`)** +- `validateAnalyticsTransfersInput()` — 验证 wallet 参数 +- `analyticsTransfersValidator` — 中间件 + +**Backend — app.ts 路由注册** +- `app.use('/api/v1/analytics', analyticsRouter)` + +**Frontend — API 客户端** +- `frontend-demo/src/api/bridgeshield.ts` — 新增 `getTransferHistory()` + 类型 +- `frontend-admin/src/api/admin-api.ts` — 新增 `getTransferHistory()` + 类型 + +**README.md — 新增 LI.FI Analytics 说明** +- 环境变量文档更新(`LI_FI_API_BASE_URL`) +- API 端点表格新增 `/analytics/transfers` +- Features 部分新增 LI.FI 增强风险信号说明 + +### 新增 API 端点 +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/analytics/transfers` | LI.FI 跨链交易历史(调查端点) | + +### 测试覆盖 +| 测试文件 | 数量 | +|----------|------| +| `behavior-analyzer-lifi.test.ts` | 9 个 LI.FI 增强测试 | +| `analytics-service.test.ts` | 10 个服务测试 | +| `analytics.test.ts` | 10 个集成测试 | +| 现有回归测试 | 104 个 | + +### 构建验证 +| 子项目 | `npm run build` | `npm test` | +|--------|-----------------|------------| +| Backend | ✅ 通过 | ✅ 133/133 | +| Frontend Demo | ✅ 通过 | — | +| Frontend Admin | ✅ 通过 | — | + +--- + ## 2026-04-11 — v0.0.5 Earn API + Composer + 行为分析 ### 概述 diff --git a/frontend-admin/package-lock.json b/frontend-admin/package-lock.json index 17eb499..1e700c6 100644 --- a/frontend-admin/package-lock.json +++ b/frontend-admin/package-lock.json @@ -15,16 +15,28 @@ "recharts": "^2.10.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^15.0.0", + "@testing-library/user-event": "^14.5.0", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.17", + "jsdom": "^24.0.0", "postcss": "^8.4.35", "tailwindcss": "^3.4.1", "typescript": "^5.3.3", - "vite": "^5.1.0" + "vite": "^5.1.0", + "vitest": "^1.6.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -38,6 +50,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -329,6 +362,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -720,6 +868,19 @@ "node": ">=12" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1174,6 +1335,13 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tanstack/query-core": { "version": "5.97.0", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.97.0.tgz", @@ -1200,6 +1368,99 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-15.0.7.tgz", + "integrity": "sha512-cg0RvEdD1TIhhkm1IeYMQxrzy0MtUNfa3minv4MjbgcYzJAZ7yD0i0lwoPOTPr+INtiXFezt2o8xMSnyHhEn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^10.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1364,6 +1625,169 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1392,6 +1816,33 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -1502,6 +1953,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1533,10 +2008,42 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -1580,6 +2087,19 @@ "node": ">=6" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1590,6 +2110,13 @@ "node": ">= 6" } }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1597,6 +2124,28 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1610,6 +2159,27 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1737,6 +2307,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1755,12 +2339,52 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1768,6 +2392,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -1775,6 +2409,13 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -1785,6 +2426,21 @@ "csstype": "^3.0.2" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.334", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", @@ -1792,6 +2448,68 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1841,12 +2559,46 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/fast-equals": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -1909,6 +2661,23 @@ "node": ">=8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -1958,63 +2727,87 @@ "node": ">=6.9.0" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true, - "license": "ISC", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": ">=10.13.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" } }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "binary-extensions": "^2.0.0" + "is-glob": "^4.0.3" }, "engines": { - "node": ">=8" + "node": ">=10.13.0" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, "engines": { "node": ">= 0.4" }, @@ -2022,42 +2815,223 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "has-symbols": "^1.0.3" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, "engines": { - "node": ">=0.12.0" + "node": ">= 0.4" } }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", @@ -2071,6 +3045,47 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2117,6 +3132,23 @@ "dev": true, "license": "MIT" }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -2135,6 +3167,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2145,6 +3187,43 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2169,6 +3248,72 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2224,6 +3369,42 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2243,6 +3424,61 @@ "node": ">= 6" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2250,6 +3486,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2290,6 +3543,25 @@ "node": ">= 6" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", @@ -2453,6 +3725,28 @@ "dev": true, "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -2470,6 +3764,36 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2650,6 +3974,27 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2727,6 +4072,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -2748,7 +4100,27 @@ ], "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" } }, "node_modules/scheduler": { @@ -2770,6 +4142,49 @@ "semver": "bin/semver.js" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2780,6 +4195,66 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2816,6 +4291,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -2883,6 +4365,13 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -2931,6 +4420,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2944,6 +4453,35 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -2951,6 +4489,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2965,6 +4513,23 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2996,6 +4561,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3085,12 +4661,247 @@ } } }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/frontend-admin/package.json b/frontend-admin/package.json index 542b92b..1dc9c99 100644 --- a/frontend-admin/package.json +++ b/frontend-admin/package.json @@ -5,7 +5,10 @@ "scripts": { "dev": "vite --port 5174", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui" }, "dependencies": { "react": "^18.2.0", @@ -22,6 +25,11 @@ "vite": "^5.1.0", "tailwindcss": "^3.4.1", "postcss": "^8.4.35", - "autoprefixer": "^10.4.17" + "autoprefixer": "^10.4.17", + "vitest": "^1.6.0", + "jsdom": "^24.0.0", + "@testing-library/react": "^15.0.0", + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/user-event": "^14.5.0" } } \ No newline at end of file diff --git a/frontend-admin/src/App.tsx b/frontend-admin/src/App.tsx index 7ce6069..910f53c 100644 --- a/frontend-admin/src/App.tsx +++ b/frontend-admin/src/App.tsx @@ -1,8 +1,11 @@ -import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'; +import { useMemo, useState } from 'react'; +import { BrowserRouter as Router, Routes, Route, Link, Navigate, Outlet, useLocation } from 'react-router-dom'; import DashboardPage from './pages/DashboardPage'; import AppealPage from './pages/AppealPage'; import WhitelistPage from './pages/WhitelistPage'; import LogsPage from './pages/LogsPage'; +import LoginPage from './pages/LoginPage'; +import { clearAdminSession, getAdminUser, isAdminAuthenticated } from './auth/session'; import './index.css'; const navigation = [ @@ -41,35 +44,88 @@ function Sidebar() { ); } -function Layout({ children }: { children: React.ReactNode }) { +function Layout({ onLogout }: { onLogout: () => void }) { + const adminUser = useMemo(() => getAdminUser(), []); + return (
-
+

Admin Dashboard

+
+ {adminUser?.username || 'admin'} + +
- {children} +
); } +function ProtectedLayout({ + authenticated, + onLogout, +}: { + authenticated: boolean; + onLogout: () => void; +}) { + if (!authenticated) { + return ; + } + + return ; +} + function App() { + const [authenticated, setAuthenticated] = useState(isAdminAuthenticated()); + return ( - - + + + ) : ( + { + setAuthenticated(true); + }} + /> + ) + } + /> + { + clearAdminSession(); + setAuthenticated(false); + }} + /> + } + > } /> } /> } /> } /> - - + + } /> + ); } -export default App; \ No newline at end of file +export default App; diff --git a/frontend-admin/src/__tests__/api/admin-api.test.ts b/frontend-admin/src/__tests__/api/admin-api.test.ts new file mode 100644 index 0000000..12592ec --- /dev/null +++ b/frontend-admin/src/__tests__/api/admin-api.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeAppeal, normalizeWhitelistEntry, buildTransferQueryString } from '../../api/admin-api'; + +describe('normalizeAppeal', () => { + it('should map fields correctly (id, ticketId, address, reason, status)', () => { + const appeal = { + id: 'appeal-123', + ticketId: 'ticket-456', + address: '0x1234567890abcdef1234567890abcdef12345678', + reason: 'Test reason', + status: 'PENDING', + contact: 'test@example.com', + createdAt: '2024-01-01T00:00:00Z', + }; + const result = normalizeAppeal(appeal); + expect(result.id).toBe('appeal-123'); + expect(result.ticketId).toBe('ticket-456'); + expect(result.address).toBe('0x1234567890abcdef1234567890abcdef12345678'); + expect(result.reason).toBe('Test reason'); + expect(result.status).toBe('PENDING'); + expect(result.contact).toBe('test@example.com'); + expect(result.createdAt).toBe('2024-01-01T00:00:00Z'); + }); + + it('should use reviewNote from appeal.notes when reviewNote is missing', () => { + const appeal = { + id: 'appeal-123', + ticketId: 'ticket-456', + address: '0x1234567890abcdef1234567890abcdef12345678', + reason: 'Test reason', + status: 'APPROVED', + notes: 'This is a note from appeal.notes', + createdAt: '2024-01-01T00:00:00Z', + }; + const result = normalizeAppeal(appeal); + expect(result.reviewNote).toBe('This is a note from appeal.notes'); + }); + + it('should use reviewNote directly when provided', () => { + const appeal = { + id: 'appeal-123', + ticketId: 'ticket-456', + address: '0x1234567890abcdef1234567890abcdef12345678', + reason: 'Test reason', + status: 'REJECTED', + reviewNote: 'This is a direct reviewNote', + notes: 'This should not be used', + createdAt: '2024-01-01T00:00:00Z', + }; + const result = normalizeAppeal(appeal); + expect(result.reviewNote).toBe('This is a direct reviewNote'); + }); + + it('should handle missing optional fields', () => { + const appeal = { + id: 'appeal-123', + ticketId: 'ticket-456', + address: '0x1234567890abcdef1234567890abcdef12345678', + reason: 'Test reason', + status: 'PENDING', + createdAt: '2024-01-01T00:00:00Z', + }; + const result = normalizeAppeal(appeal); + expect(result.contact).toBeUndefined(); + expect(result.reviewNote).toBeUndefined(); + expect(result.reviewedAt).toBeUndefined(); + }); + + it('should use reviewedAt when provided', () => { + const appeal = { + id: 'appeal-123', + ticketId: 'ticket-456', + address: '0x1234567890abcdef1234567890abcdef12345678', + reason: 'Test reason', + status: 'APPROVED', + reviewedAt: '2024-01-02T00:00:00Z', + createdAt: '2024-01-01T00:00:00Z', + }; + const result = normalizeAppeal(appeal); + expect(result.reviewedAt).toBe('2024-01-02T00:00:00Z'); + }); +}); + +describe('normalizeWhitelistEntry', () => { + it('should map all fields correctly', () => { + const entry = { + id: 'whitelist-123', + address: '0x1234567890abcdef1234567890abcdef12345678', + type: 'MANUAL', + label: 'Test Label', + chainId: 1, + expiresAt: '2025-01-01T00:00:00Z', + createdAt: '2024-01-01T00:00:00Z', + }; + const result = normalizeWhitelistEntry(entry); + expect(result.id).toBe('whitelist-123'); + expect(result.address).toBe('0x1234567890abcdef1234567890abcdef12345678'); + expect(result.type).toBe('MANUAL'); + expect(result.label).toBe('Test Label'); + expect(result.chainId).toBe(1); + expect(result.expiresAt).toBe('2025-01-01T00:00:00Z'); + expect(result.createdAt).toBe('2024-01-01T00:00:00Z'); + }); + + it('should handle missing expiresAt', () => { + const entry = { + id: 'whitelist-123', + address: '0x1234567890abcdef1234567890abcdef12345678', + type: 'AUTOMATIC', + label: 'Test Label', + chainId: 56, + createdAt: '2024-01-01T00:00:00Z', + }; + const result = normalizeWhitelistEntry(entry); + expect(result.expiresAt).toBeUndefined(); + }); +}); + +describe('buildTransferQueryString', () => { + it('should build basic query with wallet', () => { + const result = buildTransferQueryString('0x1234567890abcdef1234567890abcdef12345678'); + expect(result).toBe('wallet=0x1234567890abcdef1234567890abcdef12345678'); + }); + + it('should include optional params when provided', () => { + const result = buildTransferQueryString('0x1234567890abcdef1234567890abcdef12345678', { + status: 'completed', + fromTime: '2024-01-01T00:00:00Z', + toTime: '2024-01-31T23:59:59Z', + cursor: 'cursor-123', + }); + expect(result).toContain('wallet=0x1234567890abcdef1234567890abcdef12345678'); + expect(result).toContain('status=completed'); + expect(result).toContain('fromTime=2024-01-01T00%3A00%3A00Z'); + expect(result).toContain('toTime=2024-01-31T23%3A59%3A59Z'); + expect(result).toContain('cursor=cursor-123'); + }); + + it('should handle limit as number', () => { + const result = buildTransferQueryString('0x1234567890abcdef1234567890abcdef12345678', { + limit: 50, + }); + expect(result).toContain('limit=50'); + }); +}); diff --git a/frontend-admin/src/__tests__/auth/session.test.ts b/frontend-admin/src/__tests__/auth/session.test.ts new file mode 100644 index 0000000..1150724 --- /dev/null +++ b/frontend-admin/src/__tests__/auth/session.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + getAdminAccessToken, + getAdminUser, + saveAdminSession, + clearAdminSession, + isAdminAuthenticated, +} from '../../auth/session'; + +describe('session', () => { + const ACCESS_TOKEN_STORAGE_KEY = 'bridgeshield_admin_access_token'; + const USER_STORAGE_KEY = 'bridgeshield_admin_user'; + const mockAccessToken = 'test-access-token-123'; + const mockUser = { id: 'user-123', username: 'admin', role: 'admin' }; + + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + }); + + describe('getAdminAccessToken', () => { + it('should return null when no token is stored', () => { + expect(getAdminAccessToken()).toBeNull(); + }); + + it('should return the stored access token', () => { + localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, mockAccessToken); + expect(getAdminAccessToken()).toBe(mockAccessToken); + }); + }); + + describe('getAdminUser', () => { + it('should return null when no user is stored', () => { + expect(getAdminUser()).toBeNull(); + }); + + it('should return the parsed stored user', () => { + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(mockUser)); + expect(getAdminUser()).toEqual(mockUser); + }); + + it('should return null and clear storage when user data is invalid JSON', () => { + localStorage.setItem(USER_STORAGE_KEY, 'invalid-json'); + expect(getAdminUser()).toBeNull(); + expect(localStorage.getItem(USER_STORAGE_KEY)).toBeNull(); + }); + }); + + describe('saveAdminSession', () => { + it('should save access token and user to localStorage', () => { + saveAdminSession(mockAccessToken, mockUser); + expect(localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)).toBe(mockAccessToken); + expect(localStorage.getItem(USER_STORAGE_KEY)).toBe(JSON.stringify(mockUser)); + }); + }); + + describe('clearAdminSession', () => { + it('should remove access token and user from localStorage', () => { + localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, mockAccessToken); + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(mockUser)); + + clearAdminSession(); + + expect(localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)).toBeNull(); + expect(localStorage.getItem(USER_STORAGE_KEY)).toBeNull(); + }); + }); + + describe('isAdminAuthenticated', () => { + it('should return false when no access token is stored', () => { + expect(isAdminAuthenticated()).toBe(false); + }); + + it('should return true when access token is stored', () => { + localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, mockAccessToken); + expect(isAdminAuthenticated()).toBe(true); + }); + }); +}); diff --git a/frontend-admin/src/api/admin-api.ts b/frontend-admin/src/api/admin-api.ts index 3c57128..faa2e37 100644 --- a/frontend-admin/src/api/admin-api.ts +++ b/frontend-admin/src/api/admin-api.ts @@ -1,36 +1,52 @@ -import { Appeal, WhitelistEntry, CheckLog, DashboardStats, RiskTrendDay, RiskDistributionItem } from '../types'; +import type { + Appeal, + WhitelistEntry, + CheckLog, + DashboardStats, + RiskTrendDay, + RiskDistributionItem, + AdminLoginResponse, + TransferHistoryResponse, +} from '../types'; +import { clearAdminSession, getAdminAccessToken } from '../auth/session'; const API_BASE = import.meta.env.VITE_API_BASE_URL || ''; -async function apiFetch(endpoint: string, options: RequestInit = {}): Promise { - try { - const res = await fetch(`${API_BASE}${endpoint}`, { - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, - ...options, - }); +type ApiFetchOptions = RequestInit & { + skipAuth?: boolean; +}; - if (res.status >= 400 && res.status < 500) { - const errorData = await res.json().catch(() => ({})); - console.error(`[BridgeShield API] Client error (${res.status}):`, errorData.message || endpoint); - throw new Error(errorData.message || `Client error: ${res.status}`); +async function apiFetch(endpoint: string, options: ApiFetchOptions = {}): Promise { + const { skipAuth = false, headers, ...requestOptions } = options; + const accessToken = getAdminAccessToken(); + + const response = await fetch(`${API_BASE}${endpoint}`, { + ...requestOptions, + headers: { + 'Content-Type': 'application/json', + ...(accessToken && !skipAuth ? { Authorization: `Bearer ${accessToken}` } : {}), + ...headers, + }, + }); + + if (response.status === 401 && !skipAuth) { + clearAdminSession(); + if (typeof window !== 'undefined' && window.location.pathname !== '/login') { + window.location.replace('/login'); } + throw new Error('Session expired. Please sign in again.'); + } - if (!res.ok) { - console.warn(`[BridgeShield API] Server error (${res.status}), using mock data for ${endpoint}`); - return getMockData(endpoint, options.method || 'GET') as T; - } + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Request failed: ${response.status}`); + } - return await res.json(); - } catch (error) { - if (error instanceof Error && error.message.startsWith('Client error:')) { - throw error; - } - console.warn(`[BridgeShield API] Network/timeout error, using mock data for ${endpoint}`, error); - return getMockData(endpoint, options.method || 'GET') as T; + if (response.status === 204) { + return undefined as T; } + + return response.json() as Promise; } const normalizeAppeal = (appeal: any): Appeal => ({ @@ -55,194 +71,37 @@ const normalizeWhitelistEntry = (entry: any): WhitelistEntry => ({ createdAt: entry.createdAt, }); -// Mock data generators -function getMockData(endpoint: string, method: string): any { - // Dashboard stats - if (endpoint === '/api/v1/admin/dashboard/stats' && method === 'GET') { - return { - todayChecks: 1247, - todayBlocks: 89, - cacheHitRate: 87.2, - avgResponseTime: 124, - checksTrend: 12.3, - blocksTrend: -4.2, - } as DashboardStats; - } - - // Risk trend data - if (endpoint === '/api/v1/admin/dashboard/risk-trend' && method === 'GET') { - return [ - { date: 'Apr 03', blocks: 67, checks: 987 }, - { date: 'Apr 04', blocks: 72, checks: 1012 }, - { date: 'Apr 05', blocks: 91, checks: 1103 }, - { date: 'Apr 06', blocks: 83, checks: 1089 }, - { date: 'Apr 07', blocks: 95, checks: 1210 }, - { date: 'Apr 08', blocks: 93, checks: 1194 }, - { date: 'Apr 09', blocks: 89, checks: 1247 }, - ] as RiskTrendDay[]; - } - - // Risk distribution - if (endpoint === '/api/v1/admin/dashboard/risk-distribution' && method === 'GET') { - return { - levels: [ - { name: 'LOW', value: 65, color: '#22C55E' }, - { name: 'MEDIUM', value: 25, color: '#F59E0B' }, - { name: 'HIGH', value: 10, color: '#EF4444' }, - ] as RiskDistributionItem[], - sources: [ - { name: 'SANCTION', value: 35, color: '#3B82F6' }, - { name: 'HACKER', value: 30, color: '#8B5CF6' }, - { name: 'MIXER', value: 20, color: '#EC4899' }, - { name: 'SCAM', value: 15, color: '#F97316' }, - ] as RiskDistributionItem[], - }; - } - - // Appeals - if (endpoint.startsWith('/api/v1/admin/appeals') && method === 'GET') { - return [ - { - id: '1', - ticketId: 'APL-001', - address: '0x1234567890abcdef1234567890abcdef12345678', - reason: 'I am the owner of this address, it was incorrectly flagged as hacker associated. I have never engaged in any malicious activity.', - contact: 'user@example.com', - status: 'PENDING', - createdAt: '2026-04-09T10:30:00Z', - }, - { - id: '2', - ticketId: 'APL-002', - address: '0xabcdef1234567890abcdef1234567890abcdef12', - reason: 'This is a contract address for our DeFi protocol, it was flagged incorrectly.', - contact: 'admin@protocol.xyz', - status: 'PENDING', - createdAt: '2026-04-08T14:15:00Z', - }, - { - id: '3', - ticketId: 'APL-003', - address: '0x9876543210fedcba9876543210fedcba98765432', - reason: 'I received funds from a friend, did not know they were from a mixer.', - status: 'APPROVED', - reviewNote: 'Legitimate user, no malicious activity found', - reviewedAt: '2026-04-07T09:45:00Z', - createdAt: '2026-04-06T11:20:00Z', - }, - { - id: '4', - ticketId: 'APL-004', - address: '0xfedcba0987654321fedcba0987654321fedcba09', - reason: 'This is my personal wallet, not associated with any scams.', - status: 'REJECTED', - reviewNote: 'Address is directly associated with multiple scam transactions', - reviewedAt: '2026-04-07T13:10:00Z', - createdAt: '2026-04-05T16:30:00Z', - }, - ] as Appeal[]; - } - - // Approve appeal - if (endpoint.match(/\/api\/v1\/admin\/appeal\/.*\/approve/) && method === 'POST') { - return { success: true }; - } - - // Reject appeal - if (endpoint.match(/\/api\/v1\/admin\/appeal\/.*\/reject/) && method === 'POST') { - return { success: true }; - } - - // Whitelist - if (endpoint === '/api/v1/admin/whitelist' && method === 'GET') { - return [ - { - id: '1', - address: '0x1234567890abcdef1234567890abcdef12345678', - type: 'LIFI_OFFICIAL', - label: 'LiFi Router Contract', - chainId: 1, - createdAt: '2026-01-15T00:00:00Z', - }, - { - id: '2', - address: '0xabcdef1234567890abcdef1234567890abcdef12', - type: 'KNOWN_PROTOCOL', - label: 'Uniswap V3 Router', - chainId: 1, - createdAt: '2026-01-20T00:00:00Z', - }, - { - id: '3', - address: '0x9876543210fedcba9876543210fedcba98765432', - type: 'BRIDGE_CONTRACT', - label: 'Arbitrum Bridge', - chainId: 42161, - createdAt: '2026-02-05T00:00:00Z', - }, - { - id: '4', - address: '0xfedcba0987654321fedcba0987654321fedcba09', - type: 'APPEAL_APPROVED', - label: 'User Appeal #3', - createdAt: '2026-04-07T09:45:00Z', - }, - { - id: '5', - address: '0x1111111254fb6c44bac0bed2854e76f90643097d', - type: 'KNOWN_PROTOCOL', - label: '1inch Router', - chainId: 1, - createdAt: '2026-03-01T00:00:00Z', - }, - ] as WhitelistEntry[]; - } - - // Add to whitelist - if (endpoint === '/api/v1/admin/whitelist' && method === 'POST') { - return { success: true }; - } - - // Remove from whitelist - if (endpoint.startsWith('/api/v1/admin/whitelist/') && method === 'DELETE') { - return { success: true }; - } - - // Logs - if (endpoint === '/api/v1/admin/logs' && method === 'GET') { - const logs: CheckLog[] = []; - const riskLevels = ['LOW', 'MEDIUM', 'HIGH'] as const; - const riskFactors = ['SANCTION_SCREEN', 'HACKER_ASSOCIATED', 'MIXER_ACTIVITY', 'SCAM_RELATED', 'UNVERIFIED_CONTRACT']; - - for (let i = 0; i < 25; i++) { - const riskLevel = riskLevels[Math.floor(Math.random() * riskLevels.length)]; - logs.push({ - id: `log-${i}`, - checkId: `CHK-${1000 + i}`, - address: `0x${Math.random().toString(16).substring(2, 42)}`, - chainId: [1, 10, 42161, 137][Math.floor(Math.random() * 4)], - riskScore: Math.floor(Math.random() * 100), - riskLevel, - action: riskLevel === 'HIGH' ? 'BLOCK' : riskLevel === 'MEDIUM' ? 'REVIEW' : 'ALLOW', - riskFactors: [riskFactors[Math.floor(Math.random() * riskFactors.length)]], - processingTimeMs: Math.floor(Math.random() * 300) + 50, - cached: Math.random() > 0.3, - fallback: Math.random() > 0.9, - createdAt: new Date(Date.now() - Math.random() * 86400000).toISOString(), - }); - } - return logs; - } +const buildTransferQueryString = ( + wallet: string, + params?: { status?: string; fromTime?: string; toTime?: string; limit?: number; cursor?: string } +): string => { + const searchParams = new URLSearchParams(); + searchParams.set('wallet', wallet); + if (params?.status) searchParams.set('status', params.status); + if (params?.fromTime) searchParams.set('fromTime', params.fromTime); + if (params?.toTime) searchParams.set('toTime', params.toTime); + if (typeof params?.limit === 'number') searchParams.set('limit', String(params.limit)); + if (params?.cursor) searchParams.set('cursor', params.cursor); + return searchParams.toString(); +}; - return {}; -} +export { normalizeAppeal, normalizeWhitelistEntry, buildTransferQueryString }; -// API functions export const adminApi = { + login: (username: string, password: string) => + apiFetch('/api/v1/admin/auth/login', { + method: 'POST', + skipAuth: true, + body: JSON.stringify({ username, password }), + }), + getDashboardStats: () => apiFetch('/api/v1/admin/dashboard/stats'), getRiskTrend: () => apiFetch('/api/v1/admin/dashboard/risk-trend'), - getRiskDistribution: () => apiFetch<{ levels: RiskDistributionItem[]; sources: RiskDistributionItem[] }>('/api/v1/admin/dashboard/risk-distribution'), - + getRiskDistribution: () => + apiFetch<{ levels: RiskDistributionItem[]; sources: RiskDistributionItem[] }>( + '/api/v1/admin/dashboard/risk-distribution' + ), + getAppeals: async () => { const appeals = await apiFetch('/api/v1/admin/appeals'); return appeals.map(normalizeAppeal); @@ -258,10 +117,14 @@ export const adminApi = { const entries = await apiFetch('/api/v1/admin/whitelist'); return entries.map(normalizeWhitelistEntry); }, - addToWhitelist: (entry: Omit) => + addToWhitelist: (entry: Omit) => apiFetch('/api/v1/admin/whitelist', { method: 'POST', body: JSON.stringify(entry) }), - removeFromWhitelist: (id: string) => - apiFetch(`/api/v1/admin/whitelist/${id}`, { method: 'DELETE' }), + removeFromWhitelist: (id: string) => apiFetch(`/api/v1/admin/whitelist/${id}`, { method: 'DELETE' }), getLogs: () => apiFetch('/api/v1/admin/logs'), + + getTransferHistory: ( + wallet: string, + params?: { status?: string; fromTime?: string; toTime?: string; limit?: number; cursor?: string } + ) => apiFetch(`/api/v1/analytics/transfers?${buildTransferQueryString(wallet, params)}`), }; diff --git a/frontend-admin/src/auth/session.ts b/frontend-admin/src/auth/session.ts new file mode 100644 index 0000000..2308d99 --- /dev/null +++ b/frontend-admin/src/auth/session.ts @@ -0,0 +1,55 @@ +import type { AdminSessionUser } from '../types'; + +const ACCESS_TOKEN_STORAGE_KEY = 'bridgeshield_admin_access_token'; +const USER_STORAGE_KEY = 'bridgeshield_admin_user'; + +const getStorage = (): Storage | null => { + if (typeof window === 'undefined') { + return null; + } + + return window.localStorage; +}; + +export const getAdminAccessToken = (): string | null => { + const storage = getStorage(); + return storage?.getItem(ACCESS_TOKEN_STORAGE_KEY) || null; +}; + +export const getAdminUser = (): AdminSessionUser | null => { + const storage = getStorage(); + const raw = storage?.getItem(USER_STORAGE_KEY); + + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as AdminSessionUser; + } catch { + storage?.removeItem(USER_STORAGE_KEY); + return null; + } +}; + +export const saveAdminSession = (accessToken: string, user: AdminSessionUser): void => { + const storage = getStorage(); + if (!storage) { + return; + } + + storage.setItem(ACCESS_TOKEN_STORAGE_KEY, accessToken); + storage.setItem(USER_STORAGE_KEY, JSON.stringify(user)); +}; + +export const clearAdminSession = (): void => { + const storage = getStorage(); + if (!storage) { + return; + } + + storage.removeItem(ACCESS_TOKEN_STORAGE_KEY); + storage.removeItem(USER_STORAGE_KEY); +}; + +export const isAdminAuthenticated = (): boolean => Boolean(getAdminAccessToken()); diff --git a/frontend-admin/src/pages/LoginPage.tsx b/frontend-admin/src/pages/LoginPage.tsx new file mode 100644 index 0000000..f588b79 --- /dev/null +++ b/frontend-admin/src/pages/LoginPage.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import type { FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { adminApi } from '../api/admin-api'; +import { saveAdminSession } from '../auth/session'; + +export default function LoginPage({ onLoginSuccess }: { onLoginSuccess: () => void }) { + const navigate = useNavigate(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + if (!username.trim() || !password) { + setError('Username and password are required'); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + const session = await adminApi.login(username.trim(), password); + saveAdminSession(session.accessToken, session.user); + onLoginSuccess(); + navigate('/', { replace: true }); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'Login failed'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+

BridgeShield Admin

+

Sign in to continue

+
+ +
+
+ + setUsername(event.target.value)} + className="w-full rounded-md border border-gray-300 px-4 py-2 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="admin" + /> +
+ +
+ + setPassword(event.target.value)} + className="w-full rounded-md border border-gray-300 px-4 py-2 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="••••••••" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+ ); +} diff --git a/frontend-admin/src/test/setup.ts b/frontend-admin/src/test/setup.ts new file mode 100644 index 0000000..a9d0dd3 --- /dev/null +++ b/frontend-admin/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/frontend-admin/src/types/index.ts b/frontend-admin/src/types/index.ts index 3d12df3..5ec854d 100644 --- a/frontend-admin/src/types/index.ts +++ b/frontend-admin/src/types/index.ts @@ -55,3 +55,38 @@ export interface RiskDistributionItem { value: number; color: string; } +export interface TransferHistoryItem { + id: string; + fromAddress: string; + toAddress: string; + fromChain: number; + toChain: number; + amount: string; + amountUsd?: number; + status: string; + timestamp: string; + txHash?: string; + feeAmount?: string; + feeToken?: string; +} + +export interface TransferHistoryResponse { + transfers: TransferHistoryItem[]; + hasNext: boolean; + hasPrevious: boolean; + next: string | null; + previous: string | null; +} + +export interface AdminSessionUser { + id: string; + username: string; + role: string; +} + +export interface AdminLoginResponse { + accessToken: string; + tokenType: 'Bearer'; + expiresIn: number; + user: AdminSessionUser; +} diff --git a/frontend-admin/vitest.config.ts b/frontend-admin/vitest.config.ts new file mode 100644 index 0000000..9f20b0c --- /dev/null +++ b/frontend-admin/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + globals: true, + }, +}) diff --git a/frontend-demo/.env.example b/frontend-demo/.env.example index 90ebe0b..0f358b4 100644 --- a/frontend-demo/.env.example +++ b/frontend-demo/.env.example @@ -1,3 +1,4 @@ # Leave empty to use Vite proxy (recommended for dev) # Set to full URL for production: https://api.bridgeshield.example.com VITE_API_BASE_URL= +VITE_API_KEY=bridgeshield-demo-key diff --git a/frontend-demo/package-lock.json b/frontend-demo/package-lock.json index df5d6ab..452aaed 100644 --- a/frontend-demo/package-lock.json +++ b/frontend-demo/package-lock.json @@ -15,16 +15,28 @@ "react-router-dom": "^6.22.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^15.0.0", + "@testing-library/user-event": "^14.5.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.4.0", + "jsdom": "^24.0.0", "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "typescript": "^5.3.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^1.6.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -38,6 +50,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -272,6 +305,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -320,6 +363,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -711,6 +869,19 @@ "node": ">=12" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1165,6 +1336,13 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tanstack/query-core": { "version": "5.97.0", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.97.0.tgz", @@ -1191,6 +1369,99 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-15.0.7.tgz", + "integrity": "sha512-cg0RvEdD1TIhhkm1IeYMQxrzy0MtUNfa3minv4MjbgcYzJAZ7yD0i0lwoPOTPr+INtiXFezt2o8xMSnyHhEn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^10.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1292,6 +1563,183 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/snapshot/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1320,6 +1768,33 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -1430,14 +1905,38 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" } }, "node_modules/caniuse-lite": { @@ -1461,6 +1960,38 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1499,6 +2030,19 @@ "node": ">= 6" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1509,6 +2053,13 @@ "node": ">= 6" } }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1516,6 +2067,28 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1529,6 +2102,27 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1536,6 +2130,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1554,6 +2162,46 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1561,6 +2209,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -1568,6 +2226,28 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.334", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", @@ -1575,6 +2255,68 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1624,6 +2366,40 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1677,6 +2453,23 @@ "node": ">=8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -1753,54 +2546,87 @@ "node": ">=6.9.0" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, + "license": "MIT", "engines": { - "node": ">=10.13.0" + "node": "*" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", "dependencies": { - "binary-extensions": "^2.0.0" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", "dependencies": { - "hasown": "^2.0.2" + "is-glob": "^4.0.3" }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -1808,45 +2634,258 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "has-symbols": "^1.0.3" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1893,6 +2932,23 @@ "dev": true, "license": "MIT" }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1905,6 +2961,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1915,6 +2981,43 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -1939,6 +3042,72 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/motion-dom": { "version": "11.18.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", @@ -2009,6 +3178,42 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2029,6 +3234,61 @@ "node": ">= 6" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2036,6 +3296,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2076,6 +3353,25 @@ "node": ">= 6" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", @@ -2239,6 +3535,51 @@ "dev": true, "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2285,6 +3626,13 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2350,6 +3698,27 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2427,6 +3796,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -2448,7 +3824,27 @@ ], "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" } }, "node_modules/scheduler": { @@ -2470,6 +3866,49 @@ "semver": "bin/semver.js" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2480,6 +3919,66 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2516,6 +4015,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -2587,6 +4093,13 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -2635,6 +4148,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2648,6 +4181,35 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -2661,6 +4223,16 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2675,6 +4247,23 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2706,6 +4295,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2773,12 +4373,247 @@ } } }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/frontend-demo/package.json b/frontend-demo/package.json index c6ff419..8b6b62b 100644 --- a/frontend-demo/package.json +++ b/frontend-demo/package.json @@ -5,7 +5,10 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui" }, "dependencies": { "react": "^18.2.0", @@ -22,6 +25,11 @@ "vite": "^5.0.0", "tailwindcss": "^3.4.0", "postcss": "^8.4.0", - "autoprefixer": "^10.4.0" + "autoprefixer": "^10.4.0", + "vitest": "^1.6.0", + "jsdom": "^24.0.0", + "@testing-library/react": "^15.0.0", + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/user-event": "^14.5.0" } } \ No newline at end of file diff --git a/frontend-demo/src/__tests__/api/bridgeshield.test.ts b/frontend-demo/src/__tests__/api/bridgeshield.test.ts new file mode 100644 index 0000000..89b5d36 --- /dev/null +++ b/frontend-demo/src/__tests__/api/bridgeshield.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from 'vitest'; +import { + transformCheckResult, + buildQueryString, + readErrorMessage, + isRecord +} from '../../api/bridgeshield'; + +describe('buildQueryString', () => { + it('should build query string from params object', () => { + const params = { chainId: 1, cursor: 'abc123', status: 'ALL' }; + const result = buildQueryString(params); + expect(result).toBe('chainId=1&cursor=abc123&status=ALL'); + }); + + it('should omit undefined values', () => { + const params = { chainId: 1, cursor: undefined, status: 'ALL' }; + const result = buildQueryString(params); + expect(result).toBe('chainId=1&status=ALL'); + }); + + it('should handle empty params', () => { + const params = {}; + const result = buildQueryString(params); + expect(result).toBe(''); + }); + + it('should handle boolean and number values', () => { + const params = { active: true, count: 42, enabled: false }; + const result = buildQueryString(params); + expect(result).toBe('active=true&count=42&enabled=false'); + }); +}); + +describe('isRecord', () => { + it('should return true for valid objects', () => { + expect(isRecord({})).toBe(true); + expect(isRecord({ key: 'value' })).toBe(true); + expect(isRecord({ nested: { key: 'value' } })).toBe(true); + }); + + it('should return false for null', () => { + expect(isRecord(null)).toBe(false); + }); + + it('should return false for arrays', () => { + expect(isRecord([])).toBe(false); + expect(isRecord([1, 2, 3])).toBe(false); + }); + + it('should return false for primitives', () => { + expect(isRecord('string')).toBe(false); + expect(isRecord(123)).toBe(false); + expect(isRecord(true)).toBe(false); + expect(isRecord(undefined)).toBe(false); + expect(isRecord(Symbol('test'))).toBe(false); + }); +}); + +describe('readErrorMessage', () => { + it('should extract message from payload.message', () => { + const payload = { message: 'Custom error message' }; + const result = readErrorMessage(payload, 'Fallback message'); + expect(result).toBe('Custom error message'); + }); + + it('should extract error from payload.error', () => { + const payload = { error: 'Error field message' }; + const result = readErrorMessage(payload, 'Fallback message'); + expect(result).toBe('Error field message'); + }); + + it('should return fallback for invalid payload', () => { + expect(readErrorMessage(null, 'Fallback')).toBe('Fallback'); + expect(readErrorMessage(undefined, 'Fallback')).toBe('Fallback'); + expect(readErrorMessage('string', 'Fallback')).toBe('Fallback'); + expect(readErrorMessage(123, 'Fallback')).toBe('Fallback'); + expect(readErrorMessage([], 'Fallback')).toBe('Fallback'); + expect(readErrorMessage({}, 'Fallback')).toBe('Fallback'); + }); + + it('should prioritize message over error', () => { + const payload = { message: 'Message first', error: 'Error second' }; + const result = readErrorMessage(payload, 'Fallback'); + expect(result).toBe('Message first'); + }); +}); + +describe('transformCheckResult', () => { + it('should transform whitelisted address correctly', () => { + const backendResponse = { + address: '0x1234567890abcdef1234567890abcdef12345678', + isWhitelisted: true, + riskScore: 0, + riskLevel: 'LOW', + decision: 'ALLOW' + }; + const result = transformCheckResult(backendResponse); + expect(result.address).toBe(backendResponse.address); + expect(result.riskFactors).toEqual(['Whitelisted address']); + expect(result.recommendation).toBe('Transaction appears safe. Low risk detected.'); + }); + + it('should transform address with risk factors correctly', () => { + const backendResponse = { + address: '0xabcdef1234567890abcdef1234567890abcdef12', + riskScore: 75, + riskLevel: 'HIGH', + decision: 'BLOCK', + riskType: 'Sanctioned', + factors: { + details: ['Sanctioned address', 'High risk activity'] + } + }; + const result = transformCheckResult(backendResponse); + expect(result.address).toBe(backendResponse.address); + expect(result.riskFactors).toEqual(['Sanctioned address', 'High risk activity']); + expect(result.recommendation).toBe('Block this transaction. Risk type: Sanctioned. Do not proceed.'); + }); + + it('should handle fallback response', () => { + const backendResponse = { + address: '0xfallback1234567890abcdef1234567890abcdef', + fallback: true, + fallbackReason: 'Service unavailable' + }; + const result = transformCheckResult(backendResponse); + expect(result.address).toBe(backendResponse.address); + expect(result.fallback).toBe(true); + expect(result.fallbackReason).toBe('Service unavailable'); + expect(result.riskFactors).toEqual(['No risk factors']); + }); + + it('should extract recommendation based on decision', () => { + const blockResponse = { decision: 'BLOCK', riskType: 'Scam' }; + const blockResult = transformCheckResult(blockResponse); + expect(blockResult.recommendation).toBe('Block this transaction. Risk type: Scam. Do not proceed.'); + + const reviewResponse = { decision: 'REVIEW' }; + const reviewResult = transformCheckResult(reviewResponse); + expect(reviewResult.recommendation).toBe('Review this transaction manually before proceeding.'); + + const allowResponse = { decision: 'ALLOW' }; + const allowResult = transformCheckResult(allowResponse); + expect(allowResult.recommendation).toBe('Transaction appears safe. Low risk detected.'); + }); + + it('should handle behaviorEscalated for REVIEW decision', () => { + const backendResponse = { + decision: 'REVIEW', + behaviorEscalated: true, + behavior: { + recommendation: 'Custom behavior recommendation' + } + }; + const result = transformCheckResult(backendResponse); + expect(result.recommendation).toBe('Custom behavior recommendation'); + }); + + it('should use riskType if factors.details not available', () => { + const backendResponse = { + riskType: 'Phishing' + }; + const result = transformCheckResult(backendResponse); + expect(result.riskFactors).toEqual(['Risk type: Phishing']); + }); + + it('should set default values for missing fields', () => { + const backendResponse = { + address: '0x1234' + }; + const result = transformCheckResult(backendResponse); + expect(result.riskScore).toBe(0); + expect(result.riskLevel).toBe('LOW'); + expect(result.action).toBe('ALLOW'); + expect(result.cached).toBe(false); + expect(result.processingTimeMs).toBe(0); + expect(result.fallback).toBe(false); + expect(result.behaviorEscalated).toBe(false); + }); +}); diff --git a/frontend-demo/src/api/bridgeshield.ts b/frontend-demo/src/api/bridgeshield.ts index a5a594b..5699bb2 100644 --- a/frontend-demo/src/api/bridgeshield.ts +++ b/frontend-demo/src/api/bridgeshield.ts @@ -5,12 +5,15 @@ import { EarnPortfolioResponse, EarnVaultDetailResponse, EarnVaultListResponse, - Stats + Stats, + TransferHistoryResponse } from '../types'; const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; +const API_KEY = import.meta.env.VITE_API_KEY || ''; const TIMEOUT = 5000; +const getAmlAuthHeaders = (): Record => (API_KEY ? { 'X-API-Key': API_KEY } : {}); // Helper to create abort signal with timeout const createTimeoutSignal = (timeoutMs: number) => { const controller = new AbortController(); @@ -18,7 +21,7 @@ const createTimeoutSignal = (timeoutMs: number) => { return controller.signal; }; -const buildQueryString = (params: Record): string => { +export const buildQueryString = (params: Record): string => { const query = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { @@ -29,10 +32,10 @@ const buildQueryString = (params: Record => - typeof value === 'object' && value !== null; +export const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); -const readErrorMessage = (payload: unknown, fallback: string): string => { +export const readErrorMessage = (payload: unknown, fallback: string): string => { if (isRecord(payload) && typeof payload.message === 'string') { return payload.message; } @@ -44,7 +47,7 @@ const readErrorMessage = (payload: unknown, fallback: string): string => { return fallback; }; -function transformCheckResult(backendResponse: any): AMLCheckResult { +export function transformCheckResult(backendResponse: any): AMLCheckResult { let riskFactors: string[] = []; if (backendResponse.isWhitelisted) { riskFactors = ['Whitelisted address']; @@ -95,6 +98,7 @@ export const checkAddress = async (address: string, chainId: number = 1): Promis method: 'POST', headers: { 'Content-Type': 'application/json', + ...getAmlAuthHeaders(), }, body: JSON.stringify({ address, chainId }), signal, @@ -125,7 +129,12 @@ export const checkAddress = async (address: string, chainId: number = 1): Promis export const getWhitelist = async () => { const signal = createTimeoutSignal(TIMEOUT); - const response = await fetch(`${BASE_URL}/api/v1/aml/whitelist`, { signal }); + const response = await fetch(`${BASE_URL}/api/v1/aml/whitelist`, { + signal, + headers: { + ...getAmlAuthHeaders(), + }, + }); const payload = await response.json().catch(() => ({})); if (!response.ok) { @@ -144,7 +153,10 @@ export const submitAppeal = async (address: string, reason: string, contact: str const signal = createTimeoutSignal(TIMEOUT); const response = await fetch(`${BASE_URL}/api/v1/aml/appeal`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...getAmlAuthHeaders(), + }, body: JSON.stringify({ address, chainId: 1, reason, contact }), signal, }); @@ -309,3 +321,38 @@ export const buildCompliantComposerQuote = async (request: ComposerQuoteRequest) return payload as ComposerQuoteResponse; }; + +export interface TransferHistoryParams { + wallet: string; + status?: 'ALL' | 'PENDING' | 'DONE' | 'FAILED'; + fromTime?: string; + toTime?: string; + limit?: number; + cursor?: string; +} + +export const getTransferHistory = async (params: TransferHistoryParams): Promise => { + const signal = createTimeoutSignal(TIMEOUT); + const queryString = buildQueryString({ + wallet: params.wallet, + status: params.status, + fromTime: params.fromTime, + toTime: params.toTime, + limit: params.limit, + cursor: params.cursor + }); + + const response = await fetch(`${BASE_URL}/api/v1/analytics/transfers${queryString ? `?${queryString}` : ''}`, { + method: 'GET', + signal + }); + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + const message = (payload as { message?: string }).message || `Analytics API error: ${response.status}`; + throw new Error(message); + } + + return payload as TransferHistoryResponse; +}; diff --git a/frontend-demo/src/test/setup.ts b/frontend-demo/src/test/setup.ts new file mode 100644 index 0000000..a9d0dd3 --- /dev/null +++ b/frontend-demo/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/frontend-demo/src/types/index.ts b/frontend-demo/src/types/index.ts index de5c0e8..fbec5cf 100644 --- a/frontend-demo/src/types/index.ts +++ b/frontend-demo/src/types/index.ts @@ -155,3 +155,27 @@ export interface BehaviorProfile { recentRiskDecisionRatio: number; }; } + +export interface TransferHistoryItem { + id: string; + fromAddress: string; + toAddress: string; + fromChain: number; + toChain: number; + amount: string; + amountUsd?: number; + status: string; + timestamp: string; + txHash?: string; + feeAmount?: string; + feeToken?: string; +} + +export interface TransferHistoryResponse { + transfers: TransferHistoryItem[]; + hasNext: boolean; + hasPrevious: boolean; + next: string | null; + previous: string | null; + _bridgeShield?: BridgeShieldProxyMeta; +} diff --git a/frontend-demo/vitest.config.ts b/frontend-demo/vitest.config.ts new file mode 100644 index 0000000..9f20b0c --- /dev/null +++ b/frontend-demo/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + globals: true, + }, +}) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index dfbfe7d..926a848 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -34,6 +34,7 @@ import { BridgeShieldClient } from '@bridgeshield/sdk'; const client = new BridgeShieldClient({ baseUrl: 'https://api.bridgeshield.io', + apiKey: process.env.BRIDGESHIELD_API_KEY, }); const result = await client.checkAddress({ @@ -52,7 +53,7 @@ console.log(result.decision); // 'ALLOW' | 'REVIEW' | 'BLOCK' ```typescript const client = new BridgeShieldClient({ baseUrl: string; // Required: API base URL - apiKey?: string; // Optional: API key for authentication + apiKey?: string; // API key for protected AML/admin endpoints timeout?: number; // Optional: Request timeout in ms (default: 5000) }); ``` @@ -300,7 +301,7 @@ Works with any modern browser that supports `fetch` API. ### API Keys -For production use, you can set an API key: +Protected AML/admin endpoints require an API key. Set it on client construction: ```typescript const client = new BridgeShieldClient({ @@ -313,8 +314,3 @@ const client = new BridgeShieldClient({ MIT License - see [LICENSE](LICENSE) for details. -## Links - -- [Documentation](https://docs.bridgeshield.io) -- [API Reference](https://api.bridgeshield.io/api/v1/docs) -- [BridgeShield Website](https://bridgeshield.io) diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 673a582..2f692f8 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -11,6 +11,39 @@ export interface CheckAddressParams { senderAddress?: string; } +export interface BehaviorSignal { + type: string; + score: number; + description: string; +} + +export interface BehaviorProfile { + address: string; + chainId?: number; + level: 'LOW' | 'MEDIUM' | 'HIGH' | 'UNKNOWN'; + confidence: 'LOW' | 'MEDIUM' | 'HIGH'; + signals: string[]; + lifiSignals?: string[]; + metrics: { + checkVelocity24h?: number; + checkVelocity7d?: number; + chainNovelty?: number; + amountSpike?: number; + decisionDrift?: number; + lifiHistoryFallback?: boolean; + }; + lifiScore?: number; + lifiConfidence?: 'LOW' | 'MEDIUM' | 'HIGH'; + lifiHistory?: unknown; + lifiReferencedAddresses?: number; + lifiFirstTransaction?: { amount: string; timestamp: string }; + lifiCrossChainTumbling?: boolean; + lifiHighRiskInteraction?: boolean; + lifiAmountSpike?: boolean; + lifiHighChainDiversity?: boolean; + analyzedAt: string; +} + export interface CheckAddressResponse { checkId: string; address: string; @@ -18,6 +51,7 @@ export interface CheckAddressResponse { riskScore: number; riskLevel: RiskLevel; decision: Action; + baseDecision?: Action; riskType?: string; factors?: { details?: string[]; @@ -29,6 +63,9 @@ export interface CheckAddressResponse { expiresAt?: string; fallback?: boolean; fallbackReason?: string; + behavior?: BehaviorProfile; + behaviorEscalated?: boolean; + behaviorReason?: string; } // Appeal params and response