diff --git a/.dockerignore b/.dockerignore index 321d874..c0f8959 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,6 @@ node_modules -npm-debug.log +dist coverage .git -.github -test -README.md .env -.env.local -docker-compose.override.yml -.dockerignore \ No newline at end of file +*.log diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..79621be --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 diff --git a/.env.example b/.env.example index e6bb88a..971eb70 100644 --- a/.env.example +++ b/.env.example @@ -1,100 +1,21 @@ -# Server -PORT=4000 - -# Redis -REDIS_HOST=redis -REDIS_PORT=6379 -REDIS_PASSWORD= -REDIS_URL=redis://redis:6379 - -# Database -DATABASE_URL=postgres://smartdrop:smartdrop@postgres:5432/smartdrop -# Runtime environment -# NODE_ENV: string enum (development, test, production). Default: development. NODE_ENV=development - -# PORT: number. Default: 3000. PORT=3000 +APP_URL=http://localhost:3001 -# Database -DATABASE_URL=postgres://postgres:postgres@localhost:5432/smartdrop - -# Stellar Horizon -# REDIS_URL: URL. Required in production. Development/test default: redis://localhost:6379. -REDIS_URL=redis://localhost:6379 - -# DATABASE_URL: URL. Required in production. Development default: postgres://localhost/smartdrop. Test default: postgres://localhost/smartdrop_test. -DATABASE_URL=postgres://localhost/smartdrop - -# STELLAR_HORIZON_URL: URL. Default: https://horizon.stellar.org. -STELLAR_HORIZON_URL=https://horizon.stellar.org - -# Soroban event indexer -SOROBAN_RPC_URL=https://soroban-rpc.mainnet.stellar.gateway.fm -SMARTDROP_CONTRACT_ID= -INDEXER_ENABLED=true -INDEXER_POLL_INTERVAL_MS=5000 -INDEXER_POLL_LIMIT=100 -INDEXER_START_LEDGER=0 - -# Stellar USDC Issuer -# USDC_ISSUER: Stellar public key. Default: Stellar USDC issuer. -USDC_ISSUER=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA - -# COINGECKO_API_KEY: string. Optional. Default: empty. -COINGECKO_API_KEY= - -# COINMARKETCAP_API_KEY: string. Optional. Default: empty. -COINMARKETCAP_API_KEY= - -# PRICE_CACHE_TTL_SECONDS: number. Default: 60. -PRICE_CACHE_TTL_SECONDS=60 - -# PRICE_REFRESH_INTERVAL_SECONDS: number. Default: 30. -PRICE_REFRESH_INTERVAL_SECONDS=30 - -# PRICE_STALE_THRESHOLD_MINUTES: number. Default: 5. -PRICE_STALE_THRESHOLD_MINUTES=5 - -# PRICE_ANOMALY_THRESHOLD_PCT: number. Default: 20. -PRICE_ANOMALY_THRESHOLD_PCT=20 - -# AIRDROP_EXPIRY_CHECK_INTERVAL_SECONDS: number. Default: 60. -AIRDROP_EXPIRY_CHECK_INTERVAL_SECONDS=60 - -# AIRDROP_LEDGER_CACHE_TTL_MS: number. Default: 5000. -AIRDROP_LEDGER_CACHE_TTL_MS=5000 - -# AIRDROP_EXPIRY_SCAN_BATCH_SIZE: number. Default: 100. -AIRDROP_EXPIRY_SCAN_BATCH_SIZE=100 - -# Airdrop request limits -# Maximum recipient CSV upload size in bytes. Default: 5 MiB. -AIRDROP_CSV_MAX_BYTES=5242880 -# Maximum JSON request body size in bytes. Default: 2 MiB, sufficient for 10,000 recipients. -AIRDROP_JSON_MAX_BYTES=2097152 -# Per-IP limits for airdrop creation and recipient additions. -AIRDROP_RATELIMIT_WINDOW=60 -AIRDROP_RATELIMIT_MAX=10 - -# WATCHED_ASSETS: comma-separated CODE or CODE:ISSUER values warmed before startup. -WATCHED_ASSETS=XLM,USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN +# postgresql://user:password@host:5432/db +DATABASE_URL= -# API key auth -ADMIN_API_KEY= +# Must be at least 32 characters. +JWT_SECRET= -# LOG_LEVEL: string enum (debug, info, warn, error). Default: info. -LOG_LEVEL=info +# Soroban RPC endpoint (e.g. https://soroban-testnet.stellar.org) +SOROBAN_RPC_URL= +STELLAR_NETWORK=testnet -# Rate limiting (Redis-backed, per IP) -# RATE_LIMIT_WINDOW_MS: global API window in milliseconds. Default: 60000 (1 minute). -RATE_LIMIT_WINDOW_MS=60000 -# RATE_LIMIT_MAX: max requests per IP per global window. Default: 100. -RATE_LIMIT_MAX=100 -# PRICE_RATELIMIT_WINDOW: price endpoint window in seconds. Default: 60. -PRICE_RATELIMIT_WINDOW=60 -# PRICE_RATELIMIT_MAX: max price requests per IP per window. Default: 30. -PRICE_RATELIMIT_MAX=30 +# Deployed StellarTickets/blockchain `ticketing` contract address (C...) +TICKETING_CONTRACT_ID= -# CORS -CORS_ALLOWED_ORIGINS=http://localhost:4000,http://localhost:3001 +# Platform hot wallet used ONLY as a disposable source account for read-only +# contract simulations (verify_ticket, get_event). Never used to sign a +# write, and never a user's key. +PLATFORM_SIGNER_SECRET= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b1a78e9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.ts text +*.json text +*.md text diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9d7bc18 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @presidojay1 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..120015f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [presidojay1] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..67aac6c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,13 @@ +--- +name: Bug report +about: Report a problem with the API +labels: bug +--- + +**Endpoint affected** + +**Request/response** + +**Expected behavior** + +**Actual behavior** diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..39ab294 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Question or discussion + url: https://github.com/orgs/StellarTickets/discussions + about: Ask questions or discuss ideas before filing an issue diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..5f33f53 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,9 @@ +--- +name: Feature request +about: Suggest a new API capability +labels: enhancement +--- + +**What problem does this solve?** + +**Proposed endpoint(s) or schema changes** diff --git a/.github/ISSUE_TEMPLATE/security_report.md b/.github/ISSUE_TEMPLATE/security_report.md new file mode 100644 index 0000000..e7d0067 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/security_report.md @@ -0,0 +1,9 @@ +--- +name: Security report +about: Do not use this template — see SECURITY.md instead +labels: security +--- + +Please do not report security vulnerabilities through public GitHub +issues. See [SECURITY.md](../../SECURITY.md) for the private +disclosure process. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..20addbf --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +## Summary + +## Testing +- [ ] `npx tsc --noEmit` passes +- [ ] `npx eslint "src/**/*.ts"` passes +- [ ] `npm test` passes +- [ ] `npm run build` succeeds diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5a32bd8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..a2a193d --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,6 @@ +daysUntilStale: 60 +daysUntilClose: 14 +staleLabel: stale +markComment: > + This issue has been automatically marked as stale due to inactivity + and will be closed if no further activity occurs. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2177edc..f8bbc62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,61 +1,35 @@ name: CI on: - pull_request: - branches: - - main - push: - branches: - - main - -permissions: - contents: read + push: + branches: [main] + pull_request: + branches: [main] jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 10 - services: - redis: - image: redis:7-alpine - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - NODE_ENV: test - REDIS_HOST: 127.0.0.1 - REDIS_PORT: 6379 - COINGECKO_API_KEY: ${{ secrets.COINGECKO_API_KEY }} - COINMARKETCAP_API_KEY: ${{ secrets.COINMARKETCAP_API_KEY }} - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up Node.js 20 - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Lint OpenAPI spec - run: npx @redocly/cli lint openapi.yaml - - - name: Run tests - run: npm test - - docker-build: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Build Docker image - run: docker build -t smartdrop-backend:ci . + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Generate Prisma client + run: npx prisma generate + + - name: Typecheck + run: npx tsc --noEmit + + - name: Lint + run: npx eslint "src/**/*.ts" + + - name: Unit tests + run: npm test + + - name: Build + run: npm run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 3c58bef..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Deploy - -on: - workflow_run: - workflows: - - CI - branches: - - main - types: - - completed - -permissions: - contents: read - packages: write - -jobs: - deploy: - if: github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - environment: - name: production - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Build and push Docker image to GHCR - env: - GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} - IMAGE_NAME: ghcr.io/${{ github.repository }} - IMAGE_TAG: ${{ github.event.workflow_run.head_sha }} - run: | - echo "Stub deploy step for ${IMAGE_NAME}:${IMAGE_TAG}" - echo "Add GHCR push or external deployment integration here." - - - name: Trigger production deployment - run: echo "Deploy placeholder" diff --git a/.gitignore b/.gitignore index 9e5815e..02353e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,23 @@ -node_modules/ +# compiled output +/dist +/node_modules + +# logs +logs +*.log +npm-debug.log* + +# env .env .env.local -.env.*.local -dist/ -coverage/ -*.log + +# coverage +/coverage +/.nyc_output + +# IDE +.idea +.vscode + +# OS .DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a20502b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md new file mode 100644 index 0000000..e174516 --- /dev/null +++ b/ACKNOWLEDGEMENTS.md @@ -0,0 +1,6 @@ +# Acknowledgements + +Built with [NestJS](https://nestjs.com/), [Prisma](https://www.prisma.io/), +and the [Stellar SDK](https://github.com/stellar/js-stellar-sdk), against +the [`ticketing`](https://github.com/StellarTickets/blockchain) Soroban +contract. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..737fb9c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to the backend API are documented here. +This project follows [Keep a Changelog](https://keepachangelog.com/). + +## [Unreleased] + +### Added +- Auth (register/login, JWT) +- Organizations, events, ticket types +- Non-custodial ticket lifecycle: issue, purchase, transfer, check-in, + revoke, resale marketplace +- Users module: profile, wallet connect, email lookup diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..433bbcb --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,5 @@ +# Code of Conduct + +Be respectful, be constructive, assume good faith. Report unacceptable +behavior by opening an issue or contacting the maintainers through the +StellarTickets GitHub organization. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c286e81 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing to StellarTickets/backend + +## Development setup + +```bash +npm install +cp .env.example .env +npx prisma migrate dev +npm run start:dev +``` + +## Before opening a PR + +```bash +npx tsc --noEmit +npx eslint "src/**/*.ts" +npm test +npm run build +``` + +## Commit style + +Keep commits scoped to one logical change with an imperative subject +line. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..e20f304 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +# Contributors + +- presidojay1- chonilius- prodbycorne- Temi-suwa18- abayomicornelius- abayomiwav- circleboyslimited- miraclesonly- boluwacodes- presidoclintonbased-alt- richardtoms100- Smoothjane- Smoothjane- oluwarantimini diff --git a/Dockerfile b/Dockerfile index 9ff0706..0ec71e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,34 +1,15 @@ -# --- Base & Development Stage --- -FROM node:20-alpine AS development +FROM node:22-alpine AS build WORKDIR /app - -# Instalar dependencias completas (incluye devDependencies para nodemon/hot-reload) COPY package*.json ./ -RUN npm install --legacy-peer-deps - -# Copiar el código fuente +RUN npm ci COPY . . +RUN npx prisma generate && npm run build -EXPOSE 4000 -CMD ["npm", "run", "dev"] - -# --- Builder Stage para Producción --- -FROM node:20-alpine AS builder -WORKDIR /app - -COPY package*.json ./ -RUN npm ci --omit=dev - -COPY src ./src - -# --- Production Stage --- -FROM node:20-alpine AS production +FROM node:22-alpine WORKDIR /app ENV NODE_ENV=production - -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/src ./src -COPY package*.json ./ - -EXPOSE 4000 -CMD ["node", "src/index.js"] \ No newline at end of file +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/prisma ./prisma +EXPOSE 3000 +CMD ["node", "dist/main"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9a929c7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 StellarTickets + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..4605a85 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,5 @@ +# Maintainers + +| Name | GitHub | +|---|---| +| StellarTickets core team | [@presidojay1](https://github.com/presidojay1) | diff --git a/README.md b/README.md index bc97d55..a6faa31 100644 --- a/README.md +++ b/README.md @@ -1,734 +1,73 @@ -# SmartDrop backend +# StellarTickets — Backend + +REST API for [StellarTickets](https://github.com/StellarTickets) — +*Secure. Verifiable. Powered by Stellar.* + +Built with NestJS + Prisma (PostgreSQL). This service owns organizer/event +metadata, authentication, and the marketplace search surface — it never +custodies ticket ownership itself. The +[`ticketing`](https://github.com/StellarTickets/blockchain) Soroban contract +is the source of truth for who owns a ticket and whether it's valid; this API +reads and writes through it. + +## Non-custodial by design + +This backend never holds a user's Stellar secret key. Every on-chain action +(publishing an event, issuing/purchasing/transferring/checking in/revoking/ +reselling a ticket) is a two-step flow: + +1. **`POST /.../`** — the API simulates the contract call against the + caller's own public key and returns an unsigned, fee-prepared XDR envelope. +2. The caller's wallet (Freighter, etc.) **signs it client-side**. +3. **`POST /.../confirm-`** — the API relays the signed envelope to + Soroban RPC, polls it to completion, and updates its own read-model + (`Ticket.status`, `Event.status`, …) to match. + +See [`src/stellar/stellar.service.ts`](src/stellar/stellar.service.ts) for +the implementation and [`src/tickets/tickets.service.ts`](src/tickets/tickets.service.ts) +for how each ticket action wires into it. + +## Domain model + +One flexible schema covers all twelve supported industries (concerts, +flights, sports, festivals, conferences, bus, movie theaters, museums, +tourist attractions, public transport, universities, corporate events) — +`Event.category` is the only industry-specific field. See +[`prisma/schema.prisma`](prisma/schema.prisma). + +| Module | Responsibility | +|---|---| +| `auth` | Registration/login, JWT issuance, password hashing (bcrypt) | +| `organizations` | Organizer accounts, membership, the Stellar account that signs on-chain writes | +| `events` | Event/ticket-type CRUD, publishing an event on-chain | +| `tickets` | Issuance, primary sale, transfer, check-in, revocation, resale marketplace | +| `stellar` | The Soroban `ticketing` contract client (see above) | -[![CI](https://github.com/SmartDropLabs/smartdrop-backend/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/SmartDropLabs/smartdrop-backend/actions/workflows/ci.yml) - -HTTP APIs, webhooks, and **indexing** for SmartDrop. This repository contains Node.js services that talk to **Horizon**, **Soroban RPC**, and external APIs. - -## Related repositories - -| Repository | Role | -|------------|------| -| [**smart-frontend**](https://github.com/SmartDropLabs/smart-frontend) | Next.js static app | -| [**smartdrop-contracts**](https://github.com/SmartDropLabs/smartdrop-contracts) | Soroban Rust contracts | -| [**SmartDrop**](https://github.com/SmartDropLabs/SmartDrop) | Original monorepo (reference) | - -## Features - -### Price Oracle Service - -Multi-source price oracle that fetches and caches USD prices for Stellar assets. - -**Data Sources:** -- Stellar DEX (orderbook prices) -- CoinGecko API -- CoinMarketCap API - -**Features:** -- Median price aggregation from multiple sources -- Redis caching with configurable TTL (default: 60s) -- Background job refreshes prices every 30 seconds -- Stale price detection (>5 minutes) -- Price anomaly logging (>20% changes) -- Fallback chain: DEX → CoinGecko → CoinMarketCap → cached - -### Soroban Event Indexer - -Polls Soroban RPC for SmartDrop contract events and stores decoded event state in Redis so the API can answer claim-status queries without live RPC calls on every request. - -**Indexed events:** -- `airdrop_created` -- `recipient_added` -- `token_claimed` -- `airdrop_expired` - -**Features:** -- Configurable contract ID, RPC URL, poll interval, poll limit, and start ledger -- Last indexed ledger checkpoint persisted in Redis -- Raw XDR and decoded event data retained for each indexed event -- Aggregated airdrop status, recipient lists, recipient claim history, and indexer status endpoints -- RPC errors are logged and the poller continues on the next interval - -## Setup -### Webhook Delivery System - -Registers subscriber endpoints for SmartDrop lifecycle events and delivers signed JSON payloads with retry tracking. - -**Events:** -- `airdrop.created` -- `airdrop.executing` -- `airdrop.completed` -- `airdrop.failed` — fired automatically when an airdrop expires (see below), in addition to any other failure path -- `recipient.claimed` - -**Features:** -- Webhook endpoint CRUD with secrets kept out of list responses -- Timestamped HMAC-SHA256 request signatures -- At-least-once delivery attempts with exponential backoff -- Delivery logs with response code, error, duration, and attempt count -- Dead-letter storage after retry exhaustion - -### Airdrop Expiry Reconciliation - -Airdrops carry an `expiry_ledger`, validated as being in the future only at -creation/update time. A background job (`src/jobs/airdropExpiry.js`, same -`start()`/`stop()` pattern as the price-refresh and webhook-retry jobs) -periodically re-checks that condition against the live network: - -- Every `AIRDROP_EXPIRY_CHECK_INTERVAL_SECONDS` (default 60s), fetches the - current Horizon ledger sequence and scans every airdrop still in a - non-terminal status (`draft`, `executing`). -- Any airdrop whose `expiry_ledger` has passed is atomically transitioned to - `expired` and fires an `airdrop.failed` webhook event (`data.reason: - "expired"`) to every subscriber registered for it — no client action - required. -- The transition is idempotent: re-running the check against an - already-expired airdrop is a guaranteed no-op, so the webhook fires - exactly once per airdrop even if the job runs again before anything else - changes its status. -- If Horizon is temporarily unreachable, the job logs a warning and skips - that cycle rather than crashing — airdrops are simply re-checked on the - next tick. - -### Leader Election - -Background jobs (price refresh, webhook retry worker, airdrop expiry) use -**Redis-based leader election** to ensure that across any number of horizontally- -scaled replicas, only one instance actively runs each job at any given time. - -**Mechanism:** - -Each job type has its own Redis lock key (e.g. `leader:price_refresh`, -`leader:webhook_retry`, `leader:airdrop_expiry`). On startup, every replica -attempts to acquire the lock via `SET key value NX PX `. The instance -that succeeds becomes the **leader** and runs the actual scheduled work. All -other replicas are **followers** — they stay ready to take over, running only a -periodic renewal check loop. - -The current leader periodically renews the lease using an atomic Lua script -(`GET` + `PEXPIRE` in one round trip, keyed to only succeed if the stored value -still matches the leader's instance ID). If the leader process dies or becomes -unresponsive, the lease expires automatically after the TTL, and a follower -detects the expiry on its next renewal check and acquires leadership. - -**Failover timing:** - -| Parameter | Default | Description | -|-----------|---------|-------------| -| `LEASE_TTL_MS` | 15000 (15s) | How long a lease is valid without renewal | -| `LEASE_RENEW_INTERVAL_MS` | 5000 (5s) | How often the leader renews (and followers attempt to acquire) | - -- **Best-case failover** (leader stops gracefully): lease is released immediately - via the Lua-based conditional `DEL`; a follower acquires within one renewal - check interval (~5s). -- **Worst-case failover** (leader crashes without cleanup): lease expires after - `LEASE_TTL_MS` (15s); the next follower renewal check detects it and acquires - (up to `LEASE_TTL_MS + LEASE_RENEW_INTERVAL_MS` ≈ 20s total). - -**Verifying leadership:** - -Check the `GET /health` endpoint. Each job entry includes a `leader` field -(`true`/`false`) and `leader_instance_id` identifying which replica holds the -lock. The top-level `leader_election` object shows the local instance's identity -and lease configuration. - -```bash -curl http://localhost:4000/health | jq '.jobs.price_refresh.leader' -``` - -To see which replica holds the lock from Redis directly: - -```bash -redis-cli GET leader:price_refresh -redis-cli GET leader:webhook_retry -redis-cli GET leader:airdrop_expiry -``` - -**Graceful shutdown:** - -When a leader receives `SIGTERM`/`SIGINT`, the shutdown sequence releases the -lease via the atomic conditional-DEL Lua script before closing the Redis -connection, minimizing the failover window for followers. - -**Important caveat:** - -Leader election ensures only one instance runs the scheduled job logic, but it -does not replace the need for atomic Redis operations within individual job ticks. -For example, `deliveryRepository.popDueRetries` uses its own Lua-based atomic -claim to prevent double-processing during any brief overlap during leadership -handoffs. This is a separate concern that leader election complements but does -not solve on its own. - ---- - -## 🚀 Quick Start (Docker Development) - -You can spin up the entire local development stack—including the API, PostgreSQL database, and Redis instance—using a single command. - -### Prerequisites -* Ensure you have [Docker and Docker Compose](https://docs.docker.com/get-docker/) installed. - -### Spin Up the Stack - -1. **Clone and Navigate** to the project root directory. -2. **Set up Environment Variables**: - ```bash - cp .env.example .env - -``` - -3. **Launch the Infrastructure**: -```bash -docker compose up --build - -``` - - - -The API will stand up on [http://localhost:4000](https://www.google.com/search?q=http://localhost:4000). - -* **Hot Reloading:** Any changes made to files within the `./src` directory will instantly trigger an application restart inside the container. -* **Database & Cache:** Health checks prevent the API from booting until Postgres and Redis are fully operational. -* **Teardown:** To stop the containers and maintain volume data, run `docker compose down`. To wipe database volumes completely during stop, use `docker compose down -v`. - ---- - -## Configuration - -The application reads configurations from the `.env` file at the root. - -**Environment Variables:** - -| Variable | Description | Default | Required | -| --- | --- | --- | --- | -| `PORT` | Server port | 4000 | No | -| `REDIS_HOST` | Redis server host | redis | No | -| `REDIS_PORT` | Redis server port | 6379 | No | -| `REDIS_PASSWORD` | Redis password | undefined | No | -| `REDIS_URL` | Redis connection string | redis://redis:6379 | No | -| `DATABASE_URL` | PostgreSQL connection string | postgres://smartdrop:smartdrop@postgres:5432/smartdrop | No | -| `STELLAR_HORIZON_URL` | Horizon API URL | https://horizon.stellar.org | No | -| `SOROBAN_RPC_URL` | Soroban RPC URL for contract event polling | https://soroban-rpc.mainnet.stellar.gateway.fm | No | -| `SMARTDROP_CONTRACT_ID` | SmartDrop contract ID to index | undefined | Yes, for indexer | -| `INDEXER_ENABLED` | Enable Soroban event polling | true | No | -| `INDEXER_POLL_INTERVAL_MS` | Soroban event polling interval in milliseconds | 5000 | No | -| `INDEXER_POLL_LIMIT` | Maximum events requested per poll | 100 | No | -| `INDEXER_START_LEDGER` | First ledger to scan when no checkpoint exists | 0 | No | -| `USDC_ISSUER` | USDC issuer address | GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA | No | -| `COINGECKO_API_KEY` | CoinGecko API key | undefined | No | -| `COINMARKETCAP_API_KEY` | CoinMarketCap API key | undefined | No | -| `PRICE_CACHE_TTL` | Cache TTL in seconds | 60 | No | -| `PRICE_REFRESH_INTERVAL` | Refresh interval in seconds | 30 | No | -| `PRICE_STALE_THRESHOLD` | Stale threshold in minutes | 5 | No | -| `PRICE_ANOMALY_THRESHOLD` | Anomaly detection threshold % | 10 | No | -| `ADMIN_API_KEY` | Bootstrap admin bearer token for API key management | undefined | Yes, for protected endpoints | -| `LOG_LEVEL` | Logging level | info | No | - -| `WEBHOOK_MAX_ATTEMPTS` | Total delivery attempts (initial + retries) | 3 | No | -| `WEBHOOK_RETRY_BASE_MS` | Base backoff between retries (ms) | 30000 | No | -| `WEBHOOK_RETRY_FACTOR` | Exponential backoff multiplier | 2 | No | -| `WEBHOOK_TIMEOUT_MS` | HTTP timeout per delivery attempt | 5000 | No | -| `WEBHOOK_RETRY_POLL_MS` | Retry worker poll interval | 5000 | No | -| `WEBHOOK_RETRY_BATCH` | Max retries processed per tick | 25 | No | -| `WEBHOOK_RATELIMIT_WINDOW` | Mgmt rate-limit window (s) | 60 | No | -| `WEBHOOK_RATELIMIT_MAX` | Mgmt rate-limit max requests / window / IP | 60 | No | -| `WEBHOOK_TEST_RATELIMIT_WINDOW` | Test endpoint rate-limit window (s) | 60 | No | -| `WEBHOOK_TEST_RATELIMIT_MAX` | Test endpoint rate-limit max / window / IP | 5 | No | - -| `CORS_ALLOWED_ORIGINS` | Allowed origins split by commas | http://localhost:4000,http://localhost:3001 | No | -|----------|-------------|---------|----------| -| `NODE_ENV` | Runtime environment: `development`, `test`, or `production` | development | No | -| `PORT` | Server port | 3000 | No | -| `REDIS_URL` | Redis connection URL | redis://localhost:6379 in development/test | Yes in production | -| `DATABASE_URL` | Database connection URL reserved for persistence-backed features | postgres://localhost/smartdrop in development, postgres://localhost/smartdrop_test in test | Yes in production | -| `STELLAR_HORIZON_URL` | Horizon API URL | https://horizon.stellar.org | No | -| `USDC_ISSUER` | USDC issuer address | GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA | No | -| `COINGECKO_API_KEY` | CoinGecko API key | empty | No | -| `COINMARKETCAP_API_KEY` | CoinMarketCap API key | empty | No | -| `PRICE_CACHE_TTL_SECONDS` | Cache TTL in seconds | 60 | No | -| `PRICE_REFRESH_INTERVAL_SECONDS` | Refresh interval in seconds | 30 | No | -| `PRICE_STALE_THRESHOLD_MINUTES` | Stale threshold in minutes | 5 | No | -| `PRICE_ANOMALY_THRESHOLD_PCT` | Anomaly detection threshold % | 20 | No | -| `CIRCUIT_BREAKER_FAILURE_THRESHOLD` | Source failures before opening a price-source circuit | 3 | No | -| `CIRCUIT_BREAKER_SUCCESS_THRESHOLD` | Half-open successes required to close a circuit | 1 | No | -| `CIRCUIT_BREAKER_TIMEOUT_MS` | Open-circuit cool-down before a half-open probe | 30000 | No | -| `ADMIN_API_KEY` | Bootstrap admin bearer token for API key management | empty | Yes, for protected endpoints | -| `AIRDROP_CSV_MAX_BYTES` | Maximum recipient CSV upload size in bytes | 5242880 (5 MiB) | No | -| `AIRDROP_JSON_MAX_BYTES` | Maximum JSON request body size; 2 MiB accommodates 10,000 inline recipients | 2097152 (2 MiB) | No | -| `AIRDROP_RATELIMIT_WINDOW` | Per-IP airdrop mutation rate-limit window in seconds | 60 | No | -| `AIRDROP_RATELIMIT_MAX` | Maximum create or recipient-add requests per window and IP | 10 | No | -| `INSTANCE_ID` | Explicit instance identity for leader election; auto-generated from hostname+UUID if empty | auto | No | -| `LEASE_TTL_MS` | Leader lease TTL in milliseconds — how long a lease is valid without renewal | 15000 | No | -| `LEASE_RENEW_INTERVAL_MS` | How often the leader renews its lease (and followers check to acquire) | 5000 | No | -| `LOG_LEVEL` | Logging level: `debug`, `info`, `warn`, or `error` | info | No | - - ---- - -## API Endpoints - -### Pagination - -Every list endpoint returns the same envelope shape: - -```json -{ - "data": [ /* ... */ ], - "pagination": { - "page": 1, - "limit": 20, - "total": 42, - "total_pages": 3, - "has_next": true, - "has_prev": false - } -} -``` - -Request pagination with `?page=&limit=` (`page` defaults to 1, -`limit` defaults to 20 and is clamped to 100). This is the canonical -shape `src/schemas/pagination.js`'s `paginatedResponseSchema` defines, -now applied consistently across every list endpoint (#131 — closed -issue #35 introduced the helper but didn't get every endpoint onto it). - -List endpoints following this contract: - -- `GET /api/v1/airdrops` -- `GET /api/v1/airdrops/:id/recipients` -- `GET /api/v1/alerts` -- `GET /api/v1/webhooks` -- `GET /api/v1/airdrops/:id/onchain-recipients` -- `GET /api/v1/recipients/:address/claims` - -**Intentionally exempt:** `GET /api/v1/webhooks/:id/deliveries` takes -only `?limit=` (default 50, max 100) — deliveries are naturally -most-recent-first and capped server-side, so a `page`/offset concept -doesn't add anything; forcing it onto the same envelope would just add -an always-`page: 1`, always-`has_prev: false` `pagination` object with -no real paging behavior behind it. - -### Get Asset Price - -``` -GET /api/v1/prices/:asset_code?issuer= - -``` - -**Response:** - -```json -{ - "asset_code": "XLM", - "issuer": null, - "price_usd": 0.1234, - "source": "stellar_dex", - "fetched_at": "2024-01-15T10:30:00.000Z", - "is_stale": false, - "stale_warning": null, - "sources_attempted": ["stellar_dex", "coingecko"] -} - -``` - -### Force Price Refresh - -``` -GET /api/v1/prices/:asset_code/refresh?issuer= - -``` - -Requires `Authorization: Bearer `. - -### API Keys - -Protected endpoints use `Authorization: Bearer `. Set `ADMIN_API_KEY` to a 32-byte hex token for bootstrap access, then create scoped API keys with the key-management endpoints. - -The bootstrap admin key is compared using constant-time checks over fixed-length SHA-256 digests so invalid guesses cannot short-circuit on matching prefixes or raw string length. - -``` -GET /api/v1/keys -POST /api/v1/keys -DELETE /api/v1/keys/:id - -``` - -`POST /api/v1/keys` returns the raw `api_key` only once. Stored keys are hashed with SHA-256 and listed with metadata only (`label`, `created_at`, `last_used_at`, `scopes`, and `key_prefix`). - -### Webhook Endpoints - -``` -POST /api/v1/webhooks -GET /api/v1/webhooks -DELETE /api/v1/webhooks/:id -POST /api/v1/webhooks/:id/test -GET /api/v1/webhooks/:id/deliveries - -``` - -### Health Check - -``` -GET /health -``` - -Returns the overall health of the service and its dependencies. - -**Response fields:** - -| Field | Description | -|-------|-------------| -| `status` | Overall health: `ok`, `degraded`, or `unhealthy` | -| `timestamp` | ISO-8601 time of the response | -| `redis.connected` | `true` when the Redis client is connected | -| `jobs.price_refresh` | Health of the background price-refresh cron job | -| `jobs.webhook_retry_worker` | Health of the webhook retry worker | -| `database` | Reports `configured: true, checked: false, status: "unused"` — no active DB health probe | -| `price_source_circuits` | Per-source circuit-breaker state (open/closed) | - -**Health states:** - -| State | Meaning | -|-------|---------| -| `ok` | Redis connected; all jobs running normally | -| `degraded` | A job has not yet completed its first tick (startup grace period) | -| `unhealthy` | Redis is disconnected, or a job has stalled past its grace period | - -**Job health fields** (`jobs.price_refresh` / `jobs.webhook_retry_worker`): - -| Field | Description | -|-------|-------------| -| `healthy` | `true` while the job is running within its expected interval | -| `last_success_at` | ISO-8601 timestamp of the last successful tick, or `null` | -| `last_error` | Error message from the last failed tick, or `null` | -| `stalled` | `true` when no successful tick has occurred within 2× the job interval | - -**Example response:** - -```json -{ - "status": "ok", - "timestamp": "2024-01-15T10:30:00.000Z", - "redis": { "connected": true }, - "jobs": { - "price_refresh": { - "healthy": true, - "last_success_at": "2024-01-15T10:29:55.000Z", - "last_error": null, - "stalled": false - }, - "webhook_retry_worker": { - "healthy": true, - "last_success_at": "2024-01-15T10:29:58.000Z", - "last_error": null, - "stalled": false - } - }, - "database": { "configured": true, "checked": false, "status": "unused" }, - "price_source_circuits": [ - { "source": "coingecko", "open": false, "openUntil": null }, - { "source": "coinmarketcap", "open": false, "openUntil": null } - ] -} -``` - -### Indexed Airdrop Data - -``` -GET /api/v1/airdrops/:id/status -GET /api/v1/airdrops/:id/onchain-recipients -GET /api/v1/recipients/:address/claims -GET /api/v1/indexer/status -``` ---- - -## Usage Examples - -### Fetch XLM Price - -```bash -curl http://localhost:4000/api/v1/prices/XLM - -``` - -### Fetch Custom Asset Price - -```bash -curl "http://localhost:4000/api/v1/prices/USDC?issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA" - -``` - -### Force Price Refresh - -```bash -curl http://localhost:4000/api/v1/prices/XLM/refresh \ - -H "Authorization: Bearer $API_KEY" - -``` - -### Create API Key +## Development ```bash -curl -X POST http://localhost:4000/api/v1/keys \ - -H "Authorization: Bearer $ADMIN_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"label":"alerts worker","scopes":["alerts"]}' +npm install +cp .env.example .env # fill in DATABASE_URL, JWT_SECRET, Soroban RPC config +npx prisma migrate dev +npm run start:dev ``` -### Check Service Health +## Testing ```bash -curl http://localhost:4000/health - -``` - - -## Webhooks - -Register endpoints that receive HTTP POST callbacks when SmartDrop indexes farming/pool events. - -### Supported event types - -| Event | Description | -|-------|-------------| -| `pool.created` | A new farming pool was created on-chain | -| `pool.assets_locked` | Assets were locked into a pool | -| `pool.assets_unlocked` | Assets were unlocked from a pool | -| `pool.rewards_distributed` | Pool distributed rewards to participants | -| `pool.closed` | Pool was closed | -| `price.alert` | Existing price-alert event | -| `*` | Wildcard — subscribe to every known event | - -### API - -#### Register a webhook -``` -POST /api/v1/webhooks -Content-Type: application/json - -{ - "url": "https://example.com/webhooks/smartdrop", - "events": ["pool.assets_locked", "pool.rewards_distributed"], - "secret": "whsec_at_least_16_chars", // optional, generated if omitted - "description": "Production webhook" // optional -} -``` - -The response includes the secret in plaintext **exactly once**. Subsequent reads only return `secret_preview`. - -#### Manage webhooks -``` -GET /api/v1/webhooks # list -GET /api/v1/webhooks/:id # fetch one -PATCH /api/v1/webhooks/:id # update url / events / active / description -DELETE /api/v1/webhooks/:id # remove -``` - -#### Test endpoint -``` -POST /api/v1/webhooks/:id/test -``` -Sends a synthetic `pool.assets_locked` payload to the registered URL and returns the resulting delivery summary. Limited to 5 calls/min/IP by default. - -#### Inspect deliveries (admin dashboard feed) -``` -GET /api/v1/webhooks/:id/deliveries?limit=50 -``` -Returns the most recent delivery records: `status` (`success | pending | failed`), `attempts`, `response_status`, `last_error`, `next_retry_at`. - -### Outgoing request shape - -Every delivery is a JSON POST with the following headers: - -| Header | Value | -|--------|-------| -| `Content-Type` | `application/json` | -| `User-Agent` | `SmartDrop-Webhooks/1.0` | -| `X-SmartDrop-Event` | event type (e.g. `pool.assets_locked`) | -| `X-SmartDrop-Delivery` | unique delivery id (`dlv_…`) | -| `X-SmartDrop-Signature` | `sha256=` | - -Body: -```json -{ - "event": "pool.assets_locked", - "event_id": "evt_…", - "occurred_at": "2026-06-25T12:00:00.000Z", - "data": { "...": "event-specific fields" } -} -``` - -### Verifying the signature (Node.js) - -```js -const crypto = require('crypto'); - -function verifySmartDrop(req, secret) { - const provided = req.header('X-SmartDrop-Signature') || ''; - const expected = 'sha256=' + crypto - .createHmac('sha256', secret) - .update(req.rawBody) // verify against the RAW body, not re-stringified JSON - .digest('hex'); - const a = Buffer.from(provided); - const b = Buffer.from(expected); - return a.length === b.length && crypto.timingSafeEqual(a, b); -} -``` - -Express tip: capture the raw body via `express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString(); } })` so the HMAC matches byte-for-byte. - -### Retry & failure semantics - -- Up to `WEBHOOK_MAX_ATTEMPTS` (default 3) total attempts per event. -- Retries are scheduled in Redis and processed by a background worker, so retries survive process restarts. -- Backoff is exponential with "equal jitter": `deterministic = base * factor^(attempts-1)`, then the actual delay is randomized within `[deterministic/2, deterministic)` (default deterministic values 30s → 60s → 120s, so e.g. attempt 1's actual delay lands somewhere in 15s–30s). This prevents deliveries that fail at the same attempt count around the same moment (e.g. every in-flight delivery to a subscriber whose endpoint just went down) from computing identical `nextRetryAt` values and arriving back at that endpoint in a synchronized burst. -- **Retryable**: network errors, HTTP 5xx, 408, 429. -- **Not retried**: HTTP 4xx (except 408/429). These are marked `failed` immediately so a misconfigured consumer cannot be retried into the ground. -- Each delivery is logged in `webhook_deliveries` (Redis-backed today, drop-in PG migration documented in `src/repositories/deliveryRepository.js`). -- **Safe for multiple replicas**: `webhookRetryWorker` claims due retries via `deliveryRepository.popDueRetries`, which uses a single atomic Redis Lua script (`ZRANGEBYSCORE` + `ZREM` in one round trip) rather than two separate calls. Running N instances of this backend against the same Redis is safe - each due retry is claimed by exactly one instance, so a delivery is never dispatched twice for the same retry. The worker's in-process `running` flag only guards against a single process overlapping with itself; cross-replica safety comes from the atomic claim, not from that flag. - -### Storage model - -The current implementation stores webhooks and delivery logs in Redis behind a repository abstraction. The repository files document the equivalent PostgreSQL schema verbatim — migrating to PG is a matter of swapping the repository implementation only; no caller code changes. - -### Rate limiting - -- Management endpoints under `/api/v1/webhooks`: 60 req/min/IP (configurable). -- `/test` endpoint: 5 req/min/IP (configurable) — prevents using SmartDrop as an outbound HTTP cannon. -- The limiter fails **open** if Redis is unreachable so a cache outage does not lock you out of management calls. - ---- - - -## Error Handling - -The API returns appropriate HTTP status codes: - -* `200` - Success -* `400` - Invalid request parameters -* `404` - Price not available -* `500` - Internal server error - -**Error Response Format:** - -```json -{ - "error": "Error type", - "message": "Detailed error message" -} - -``` - ---- - -## Development - -### Project Structure - -``` -src/ -├── index.js # Express server entry point -├── config.js # Configuration management -├── logger.js # Winston logger setup -├── routes/ -│ └── prices.js # Price API endpoints -├── services/ -│ ├── cache.js # Redis cache wrapper -│ ├── priceOracle.js # Core oracle aggregation logic -│ └── sources/ -│ ├── stellarDex.js # Stellar DEX price source -│ ├── coingecko.js # CoinGecko API source -│ └── coinmarketcap.js # CoinMarketCap API source -└── jobs/ - └── priceRefresh.js # Background price refresh job - -``` - -### Adding New Price Sources - -To add a new price source: - -1. Create a new file in `src/services/sources/` -2. Implement a `fetchPrice(assetCode, issuer)` function that returns a price or `null` -3. Add the source to the `SOURCES` array in `src/services/priceOracle.js` - -Example: - -```javascript -// src/services/sources/customSource.js -const axios = require('axios'); -const logger = require('../../logger'); - -async function fetchPrice(assetCode, issuer) { - try { - const response = await axios.get('[https://api.example.com/price](https://api.example.com/price)', { - params: { asset: assetCode } - }); - return response.data.price; - } catch (err) { - logger.warn('Custom source fetch failed', { assetCode, error: err.message }); - return null; - } -} - -module.exports = { fetchPrice }; - +npm test # unit tests +npm run lint ``` ---- - -## Troubleshooting - -### Redis Connection Issues - -If you see "Redis connection error" in logs: - -* Verify containers are running: `docker compose ps` -* Check Redis logs: `docker compose logs redis` -* Ensure environmental parameters (`REDIS_HOST=redis`) reference the compose network alias rather than `localhost`. -- Verify Redis is running: `redis-cli ping` -- Check `REDIS_URL` in `.env` -- If Redis requires a password, include it in the connection URL - -### Price Not Available - -If prices return `null`: - -* Check that at least one price source is configured -* Verify API keys for CoinGecko/CoinMarketCap if using those sources -* Check logs for specific source errors -* Stellar DEX may have no liquidity for the asset - -### Rate Limiting - -External APIs may rate limit requests: - -* CoinGecko: Free tier has rate limits -* CoinMarketCap: Requires API key for production use -* The service handles rate limits gracefully and falls back to other sources - ---- - -## Monitoring - -The service logs important events: - -* Price fetches from each source -* Price anomalies (>10% changes) -* Stale price warnings -* Cache refresh cycles -* API errors -- Price fetches from each source -- Price anomalies (>20% changes) -- Stale price warnings -- Cache refresh cycles -- API errors - -Monitor logs for: +## Environment -* Frequent source failures -* Price anomalies (may indicate market volatility or data issues) -* Stale prices (may indicate cache or source issues) +See [`.env.example`](.env.example). `TICKETING_CONTRACT_ID` must point at a +deployed instance of the +[`ticketing`](https://github.com/StellarTickets/blockchain) contract. +`PLATFORM_SIGNER_SECRET` is used only as a disposable source account for +read-only simulations — it never signs a write. -## License +## More documentation -MIT +See [`docs/`](docs/README.md) for architecture, database, API, and FAQ. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..f4cf491 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,9 @@ +# Roadmap + +- [x] Auth, organizations, events, ticket types +- [x] Non-custodial ticket lifecycle +- [x] Resale marketplace +- [ ] Rate limiting on auth endpoints +- [ ] Webhook notifications for organizers +- [ ] Batch ticket issuance endpoint +- [ ] On-chain event indexer for faster marketplace queries diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b2c9faf --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,16 @@ +# Security Policy + +## Reporting a vulnerability + +This API never stores user private keys and relies on the +`ticketing` Soroban contract as the source of truth for ticket +ownership (see [stellar/blockchain](https://github.com/StellarTickets/blockchain)). +If you find an authorization bypass, an injection vector, or a way to +forge a JWT, please report it privately through the StellarTickets +GitHub organization rather than opening a public issue. + +## Supported versions + +| Version | Supported | +|---|---| +| 0.0.x | :white_check_mark: | diff --git a/TODO.md b/TODO.md deleted file mode 100644 index e44d4d5..0000000 --- a/TODO.md +++ /dev/null @@ -1,47 +0,0 @@ -# Leader Election Implementation — TODO - -## Completed Steps - -### Step 1: Create `src/services/leaderElection.js` -- [x] Implement Redis lease-based distributed lock -- [x] `tryAcquire()` — SET key value NX PX -- [x] `renew()` — Lua script for atomic check-and-renew -- [x] `release()` — Lua script for atomic conditional DEL -- [x] `isLeader()` — check if current instance holds lease -- [x] `getCurrentLeader()` — get current lease holder from Redis -- [x] Periodic renewal loop (start/stop) -- [x] Clear logging on state transitions - -### Step 2: Update `src/config.js` -- [x] Add `INSTANCE_ID` env var (default: auto-generated from hostname + random suffix) -- [x] Add `LEASE_TTL_MS` env var (default: 15000) -- [x] Add `LEASE_RENEW_INTERVAL_MS` env var (default: 5000) -- [x] Export `leaderElection` config section - -### Step 3: Create `src/jobs/leaderAwareJob.js` -- [x] Factory that wraps job modules -- [x] Only activates underlying job when leader -- [x] Reacts to leadership transitions (acquire/renew/release) -- [x] Graceful lease release on stop -- [x] Extended getHealth() with leadership info -- [x] Clear logging: "acting as follower" / "acquired leader lease" - -### Step 4: Update `src/index.js` -- [x] Import leader election service and leader-aware job wrapper -- [x] Create leader election instances for price_refresh, webhook_retry, airdrop_expiry -- [x] Wrap all three background jobs with leader-aware wrapper -- [x] Use wrapped jobs in startServer() -- [x] Use wrapped jobs in shutdown() (await stop for graceful lease release) -- [x] Update health endpoint to include leadership state per job -- [x] Add `leader_election` section to health response -- [x] Clean up duplicate/broken code - -### Step 5: Update `README.md` -- [ ] Document leader-election mechanism -- [ ] Document failover timing (TTL + renewal gap) -- [ ] New env vars table entries -- [ ] How to verify which replica holds the lock - -### Step 6: Create tests in `test/leaderElection.test.js` -- [x] Test file created with comprehensive test suite - diff --git a/docker-compose.yml b/docker-compose.yml index f899ec0..de706da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,68 +1,14 @@ - services: postgres: - image: postgres:15-alpine - container_name: smartdrop_postgres + image: postgres:16-alpine environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: smartdrop + POSTGRES_USER: stellartickets + POSTGRES_PASSWORD: stellartickets + POSTGRES_DB: stellartickets ports: - "5432:5432" volumes: - - postgres_data:/var/lib/postgresql/data - restart: unless-stopped - - redis: - image: redis:7-alpine - container_name: smartdrop_redis - ports: - - "6379:6379" - restart: unless-stopped + - postgres-data:/var/lib/postgresql/data volumes: - postgres_data: -services: - api: - build: - context: . - target: development - ports: - - "4000:4000" - environment: - - PORT=4000 - - REDIS_URL=redis://redis:6379 - - REDIS_HOST=redis - - REDIS_PORT=6379 - - DATABASE_URL=postgres://smartdrop:smartdrop@postgres:5432/smartdrop - depends_on: - redis: - condition: service_healthy - postgres: - condition: service_healthy - volumes: - - ./src:/app/src # Hot reload en desarrollo - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 3s - retries: 5 - - postgres: - image: postgres:16-alpine - environment: - POSTGRES_USER: smartdrop - POSTGRES_PASSWORD: smartdrop - POSTGRES_DB: smartdrop - ports: - - "5432:5432" - healthcheck: - test: ["CMD", "pg_isready", "-U", "smartdrop"] - interval: 5s - timeout: 3s - retries: 5 + postgres-data: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..94248a1 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,14 @@ +# API surface + +| Area | Base path | +|---|---| +| Health | `GET /health` | +| Auth | `POST /auth/register`, `POST /auth/login` | +| Users | `GET /users/me`, `PATCH /users/me/wallet`, `GET /users/lookup` | +| Organizations | `POST /organizations`, `GET /organizations/mine`, `GET /organizations/:id` | +| Events | `GET /events`, `GET /events/:id`, `POST /organizations/:id/events`, `POST /events/:id/ticket-types`, `POST /events/:id/publish` + `confirm-publish` | +| Tickets | `POST /tickets/issue` \| `purchase` + confirm variants, `GET /tickets/verify/:qrSecret`, `GET /tickets/mine`, `GET /tickets/resale`, and per-ticket `transfer` / `check-in` / `revoke` / `list-resale` / `cancel-resale` / `buy-resale` + their `confirm-*` counterparts | + +Every `confirm-*` endpoint relays a wallet-signed XDR envelope +produced by the matching build endpoint — see the root README for the +full non-custodial flow. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..dfa155f --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,25 @@ +# Architecture + +## Module layout + +- `auth` — registration, login, JWT issuance and validation +- `users` — profile, wallet connect, email lookup for recipient resolution +- `organizations` — organizer accounts and membership +- `events` — event/ticket-type CRUD and on-chain publishing +- `tickets` — the full ticket lifecycle (issue, purchase, transfer, + check-in, revoke, resale) +- `stellar` — the only module that talks to Soroban RPC + +## The non-custodial write path + +Every on-chain write follows the same two-step shape: + +1. `build*Tx` in `StellarService` simulates the call against the + caller's own public key and returns an unsigned XDR envelope. +2. The caller's wallet signs it client-side. +3. `submitSignedTransaction` relays the signed envelope and polls it + to completion. + +No other module calls Soroban RPC directly — they all go through +`StellarService`, which keeps the "we never hold a key" invariant in +one place. diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md new file mode 100644 index 0000000..8a04db4 --- /dev/null +++ b/docs/AUTHENTICATION.md @@ -0,0 +1,12 @@ +# Authentication + +JWT bearer tokens, issued by `AuthService.login`/`register` and +validated by `JwtStrategy`. Passwords are hashed with bcrypt (12 +rounds) — see `src/auth/auth.service.ts`. + +There is no refresh token flow yet; tokens expire after 1 hour +(`JwtModule.registerAsync` in `src/auth/auth.module.ts`) and the +client must re-authenticate. `RolesGuard` exists for future +role-gated routes but isn't currently applied to any controller — +authorization today is handled per-resource (organization membership +checks in `OrganizationsService.assertMember`). diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..dc00fc9 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,9 @@ +# Configuration + +All environment variables are validated at boot by +`src/config/env.validation.ts` — the app refuses to start rather than +run with a missing or malformed value. See +[`.env.example`](../.env.example) for the full list and +[`docs/AUTHENTICATION.md`](AUTHENTICATION.md) / +[`docs/NON_CUSTODIAL.md`](NON_CUSTODIAL.md) for what `JWT_SECRET` and +`PLATFORM_SIGNER_SECRET` are actually used for. diff --git a/docs/CORS.md b/docs/CORS.md new file mode 100644 index 0000000..b9de509 --- /dev/null +++ b/docs/CORS.md @@ -0,0 +1,6 @@ +# CORS + +`main.ts` calls `app.enableCors({ origin: APP_URL, credentials: true })` +— only the single configured frontend origin is allowed. If the +frontend is ever served from multiple origins (staging + production), +`APP_URL` will need to become a list rather than a single string. diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..c134029 --- /dev/null +++ b/docs/DATABASE.md @@ -0,0 +1,22 @@ +# Database + +Postgres via Prisma. Schema: [`prisma/schema.prisma`](../prisma/schema.prisma). + +## Key relationships + +- `User` — `OrganizationMember` (many-to-many via join table) — `Organization` +- `Organization` — `Event` — `TicketType` — `Ticket` +- `Ticket.ownerId` -> `User` (the current owner, kept in sync with the + on-chain owner on every write path and by `verify`) +- `ResaleListing` — one row per listing attempt, `ticketId` + `status` + +## Migrations + +```bash +npx prisma migrate dev --name +npx prisma migrate deploy # production +``` + +`chainEventId` and `chainTicketId` are unique `BigInt` columns mapping +1:1 to the on-chain `u64` ids — see `docs/ARCHITECTURE.md` for why the +chain remains the source of truth despite this cache. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..20a0c38 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,10 @@ +# Deployment checklist + +1. `npx tsc --noEmit` — clean typecheck +2. `npx eslint "src/**/*.ts"` — no lint warnings +3. `npm test` — full suite green +4. `npx prisma migrate deploy` against the production database +5. Set `TICKETING_CONTRACT_ID` to the mainnet-deployed contract address +6. Set `STELLAR_NETWORK=mainnet` and a production `SOROBAN_RPC_URL` +7. Rotate `JWT_SECRET` and `PLATFORM_SIGNER_SECRET` out of any shared + `.env` file into a real secrets manager before going live diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md new file mode 100644 index 0000000..588e8f6 --- /dev/null +++ b/docs/ERROR_HANDLING.md @@ -0,0 +1,14 @@ +# Error handling + +Nest's built-in HTTP exception filter handles everything — services +throw `NotFoundException`, `ForbiddenException`, `ConflictException`, +`BadRequestException`, etc. from `@nestjs/common`, and Nest serializes +them to `{ statusCode, message, error }` automatically. + +`ValidationPipe` (registered globally in `main.ts`) rejects any +request body that doesn't match its DTO's `class-validator` decorators +before the request ever reaches a controller method. + +There's no custom global exception filter yet — see +`docs/OBSERVABILITY.md` for what's still missing before a production +deploy (structured error logging in particular). diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 0000000..cabb45f --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,17 @@ +# FAQ + +**Why doesn't this API ever hold a user's Stellar secret key?** +Because it's non-custodial by design — see `docs/ARCHITECTURE.md` and +`src/stellar/stellar.service.ts` for the build/sign/submit pattern +every write follows. + +**What happens if a `confirm-*` call is retried after the transaction +already landed?** +Soroban RPC will reject a duplicate submission of the same signed +envelope; the caller should treat that as success if `getTransaction` +shows the hash already succeeded, rather than retrying `build-*` again. + +**Why is `TicketType.price` a string in the API, not a number?** +It's a `BigInt` in Postgres to match the contract's `i128`, and +JavaScript numbers lose precision above 2^53 — strings round-trip +exactly through JSON. diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md new file mode 100644 index 0000000..a2d0d2b --- /dev/null +++ b/docs/GLOSSARY.md @@ -0,0 +1,14 @@ +# Glossary + +- **Organization** — an issuer account (venue, airline, promoter, + etc.) that owns events and signs on-chain writes for them. +- **Event** — a concert, flight, match, etc; owns ticket types and a + resale policy, mirrored on-chain via `chainEventId`. +- **TicketType** — a tier (GA, VIP) with a face-value price and a + fixed quantity, scoped to one event. +- **Ticket** — one issued asset, mirrored on-chain via `chainTicketId`; + `qrSecret` is the opaque code embedded in its scannable code. +- **ResaleListing** — an active/sold/cancelled record of a ticket + being offered on the marketplace. +- **build/confirm pair** — the two-step non-custodial pattern every + on-chain write follows; see `docs/ARCHITECTURE.md`. diff --git a/docs/NON_CUSTODIAL.md b/docs/NON_CUSTODIAL.md new file mode 100644 index 0000000..b74691d --- /dev/null +++ b/docs/NON_CUSTODIAL.md @@ -0,0 +1,15 @@ +# Why non-custodial, specifically + +Every write to the `ticketing` contract requires `require_auth()` from +the account taking the action — the contract has no concept of an +admin override for ticket ownership. That means the only way this API +could act on a user's behalf is by holding their private key, which +would make StellarTickets a single point of failure for every ticket +on the platform. + +Instead, `StellarService.build*Tx` methods return unsigned XDR built +against the caller's own public key, and the caller's wallet signs it. +The API only ever submits transactions someone else already signed. +`PLATFORM_SIGNER_SECRET` exists only for read-only simulations +(`verify_ticket`, `get_event`), which don't call `require_auth` at +all — see `src/stellar/stellar.service.ts`. diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md new file mode 100644 index 0000000..45e0e7c --- /dev/null +++ b/docs/OBSERVABILITY.md @@ -0,0 +1,11 @@ +# Observability + +There's no structured logging or metrics pipeline wired up yet — +Nest's default console logger is all that's active. Before a real +production deploy, prioritize: + +1. Structured request logging (method, path, status, latency) via an + interceptor +2. Error tracking (e.g. Sentry) on unhandled exceptions +3. A dashboard for `StellarService` call latency/failure rate, since + that's the module most exposed to external (Soroban RPC) failure diff --git a/docs/PRISMA_7_NOTE.md b/docs/PRISMA_7_NOTE.md new file mode 100644 index 0000000..a52fb86 --- /dev/null +++ b/docs/PRISMA_7_NOTE.md @@ -0,0 +1,7 @@ +# Why Prisma is pinned to 6.x + +Prisma 7 requires moving the datasource URL out of `schema.prisma` +into a `prisma.config.ts` + driver adapter (`@prisma/adapter-pg`) +setup. That's a real architectural change, not a drop-in upgrade, and +wasn't worth adopting on day one of this project. Revisit once the +driver-adapter pattern is more established across the ecosystem. diff --git a/docs/RATE_LIMITING.md b/docs/RATE_LIMITING.md new file mode 100644 index 0000000..6af1ea8 --- /dev/null +++ b/docs/RATE_LIMITING.md @@ -0,0 +1,6 @@ +# Rate limiting + +Not implemented yet. `/auth/login` and `/auth/register` are the +highest-priority endpoints to rate-limit (credential stuffing, +account enumeration) — `@nestjs/throttler` is the natural fit given +this is already a NestJS app. Tracked in `ROADMAP.md`. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b1ef796 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,18 @@ +# Docs index + +- [ARCHITECTURE.md](ARCHITECTURE.md) — module layout and the non-custodial write path +- [API.md](API.md) — endpoint summary +- [DATABASE.md](DATABASE.md) — schema and migrations +- [TESTING.md](TESTING.md) — test suite notes +- [DEPLOYMENT.md](DEPLOYMENT.md) — deployment checklist +- [GLOSSARY.md](GLOSSARY.md) — terminology +- [FAQ.md](FAQ.md) — frequently asked questions +- [AUTHENTICATION.md](AUTHENTICATION.md) — JWT and password hashing +- [NON_CUSTODIAL.md](NON_CUSTODIAL.md) — why the build/sign/submit pattern exists +- [VALIDATION.md](VALIDATION.md) — DTO validation approach +- [ERROR_HANDLING.md](ERROR_HANDLING.md) — exception handling +- [OBSERVABILITY.md](OBSERVABILITY.md) — logging/metrics gaps +- [RATE_LIMITING.md](RATE_LIMITING.md) — known gap +- [CORS.md](CORS.md) — allowed origins +- [CONFIGURATION.md](CONFIGURATION.md) — environment variables +- [PRISMA_7_NOTE.md](PRISMA_7_NOTE.md) — why Prisma is pinned to 6.x diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..b60ce9d --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,18 @@ +# Testing + +Every service is unit tested with Prisma, `OrganizationsService`, and +`StellarService` mocked out — no real database or Soroban RPC call +happens in `npm test`. `StellarService` is mocked at the module level +(`jest.mock('../stellar/stellar.service', ...)`) rather than imported +for real, because `@stellar/stellar-sdk` ships transitive ESM-only +dependencies that need a wider `transformIgnorePatterns` to parse. + +Run the suite: + +```bash +npm test +``` + +For a true end-to-end check against a real Postgres and Soroban RPC, +see `test/app.e2e-spec.ts` (not run in CI yet — no database service is +provisioned there). diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..aae01c5 --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,12 @@ +# Input validation + +Every DTO uses `class-validator` decorators, enforced globally by the +`ValidationPipe` registered in `main.ts` with `whitelist: true` and +`forbidNonWhitelisted: true` — any field not declared on a DTO is +stripped, and any extra field in the request body causes a 400 rather +than being silently ignored. + +`IsStellarPublicKey` (in `src/common/decorators`) validates the +ed25519 checksum via `@stellar/stellar-sdk`'s `StrKey`, not a regex — +a string that merely looks like a Stellar address but fails the +checksum is rejected before it ever reaches a service. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..4e9f827 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,35 @@ +// @ts-check +import eslint from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['eslint.config.mjs'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + eslintPluginPrettierRecommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + sourceType: 'commonjs', + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + "prettier/prettier": ["error", { endOfLine: "auto" }], + }, + }, +); diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/openapi.yaml b/openapi.yaml deleted file mode 100644 index 8647330..0000000 --- a/openapi.yaml +++ /dev/null @@ -1,931 +0,0 @@ -openapi: 3.0.3 -info: - title: SmartDrop API - description: | - SmartDrop backend services — price oracle, webhook delivery, health monitoring, and indexing. - - This specification covers all current REST endpoints and planned future endpoints. - version: 0.1.0 - license: - name: MIT - url: https://opensource.org/licenses/MIT - contact: - name: SmartDrop Labs - url: https://github.com/SmartDropLabs - -servers: - - url: http://localhost:4000 - description: Local development - - url: https://api.smartdrop.app - description: Production (planned) - -security: [] - -paths: - /health: - get: - operationId: healthCheck - summary: Service health check - description: Returns the current health status of the API server and its Redis connection. - tags: - - Health - responses: - '200': - description: Service is healthy - content: - application/json: - schema: - $ref: '#/components/schemas/HealthResponse' - example: - status: ok - timestamp: '2026-06-27T12:00:00.000Z' - redis_connected: true - redis_unavailable: false - '503': - description: Service is not healthy - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: Service unavailable - message: Redis connection failed - '500': - $ref: '#/components/responses/InternalError' - - /api/v1/prices/{asset_code}: - get: - operationId: getAssetPrice - summary: Get asset price - description: | - Returns the current USD price for a Stellar asset. - If no issuer is provided, native asset (XLM) is assumed. - tags: - - Prices - parameters: - - name: asset_code - in: path - required: true - schema: - $ref: '#/components/schemas/AssetCode' - description: Stellar asset code (1–12 uppercase alphanumeric characters) - example: XLM - - name: issuer - in: query - required: false - schema: - $ref: '#/components/schemas/StellarAddress' - description: Stellar issuer public key (G…) - example: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA - x-rate-limit: - window: 60s - max: 30 - responses: - '200': - description: Price data for the requested asset - content: - application/json: - schema: - $ref: '#/components/schemas/PriceResponse' - example: - asset_code: XLM - issuer: null - price_usd: 0.1234 - source: stellar_dex - fetched_at: '2026-06-27T12:00:00.000Z' - is_stale: false - stale_warning: null - sources_attempted: - - stellar_dex - - coingecko - redis_unavailable: false - '400': - $ref: '#/components/responses/ValidationError' - '404': - description: Price data is not available for the requested asset - content: - application/json: - schema: - $ref: '#/components/schemas/PriceNotFoundResponse' - example: - error: Price not available - message: 'No price data found for UNKNOWN' - asset_code: UNKNOWN - issuer: null - price_usd: null - source: unavailable - fetched_at: '2026-06-27T12:00:00.000Z' - is_stale: true - stale_warning: 'No price data available from any source' - sources_attempted: [] - redis_unavailable: false - '500': - $ref: '#/components/responses/InternalError' - - /api/v1/prices/batch: - post: - operationId: batchGetPrices - summary: Get prices for multiple assets (planned) - description: | - **⚠️ Planned — not yet implemented.** - Accepts a list of asset identifiers and returns prices for all of them - in a single request. - tags: - - Prices - x-draft: true - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/BatchPriceRequest' - example: - assets: - - asset_code: XLM - issuer: null - - asset_code: USDC - issuer: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA - - asset_code: BTC - issuer: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA - responses: - '200': - description: Batch price results - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/PriceResponse' - '400': - $ref: '#/components/responses/ValidationError' - '422': - $ref: '#/components/responses/UnprocessableEntity' - '500': - $ref: '#/components/responses/InternalError' - - /api/v1/webhooks: - post: - operationId: createWebhookEndpoint - summary: Register a webhook endpoint - description: | - Creates a new webhook endpoint that will receive signed POST requests - when SmartDrop lifecycle events occur. - tags: - - Webhooks - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateWebhookRequest' - example: - url: https://example.com/webhooks/smartdrop - events: - - airdrop.completed - - recipient.claimed - secret: whsec_myverys3cretkey - x-rate-limit: - window: 60s - max: 20 - responses: - '201': - description: Webhook endpoint created - content: - application/json: - schema: - $ref: '#/components/schemas/WebhookEndpoint' - example: - id: wh_a1b2c3d4e5f6g7h8 - url: https://example.com/webhooks/smartdrop - events: - - airdrop.completed - - recipient.claimed - active: true - secret_preview: whse...key - created_at: '2026-06-27T12:00:00.000Z' - updated_at: '2026-06-27T12:00:00.000Z' - '400': - $ref: '#/components/responses/ValidationError' - '429': - $ref: '#/components/responses/RateLimited' - '500': - $ref: '#/components/responses/InternalError' - get: - operationId: listWebhookEndpoints - summary: List all registered webhook endpoints - description: | - Returns webhook endpoints (secrets are excluded; only a preview is - shown), in the canonical pagination envelope every list endpoint in - this API uses — see `PaginationMeta`. - tags: - - Webhooks - parameters: - - $ref: '#/components/parameters/PageParam' - - $ref: '#/components/parameters/LimitParam' - responses: - '200': - description: Paginated list of webhook endpoints - content: - application/json: - schema: - type: object - properties: - data: - type: array - items: - $ref: '#/components/schemas/WebhookEndpoint' - pagination: - $ref: '#/components/schemas/PaginationMeta' - example: - data: - - id: wh_a1b2c3d4e5f6g7h8 - url: https://example.com/webhooks/smartdrop - events: - - airdrop.completed - active: true - secret_preview: whse...key - created_at: '2026-06-27T12:00:00.000Z' - updated_at: '2026-06-27T12:00:00.000Z' - pagination: - page: 1 - limit: 20 - total: 1 - total_pages: 1 - has_next: false - has_prev: false - '500': - $ref: '#/components/responses/InternalError' - - /api/v1/webhooks/{id}: - delete: - operationId: deleteWebhookEndpoint - summary: Remove a webhook endpoint - description: Soft-deletes a webhook endpoint by marking it inactive. - tags: - - Webhooks - parameters: - - name: id - in: path - required: true - schema: - $ref: '#/components/schemas/WebhookId' - description: Webhook endpoint ID - example: wh_a1b2c3d4e5f6g7h8 - responses: - '200': - description: Webhook endpoint removed - content: - application/json: - schema: - type: object - properties: - deleted: - type: boolean - enum: - - true - webhook: - $ref: '#/components/schemas/WebhookEndpoint' - example: - deleted: true - webhook: - id: wh_a1b2c3d4e5f6g7h8 - url: https://example.com/webhooks/smartdrop - events: - - airdrop.completed - active: false - secret_preview: whse...key - created_at: '2026-06-27T12:00:00.000Z' - updated_at: '2026-06-27T12:00:01.000Z' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalError' - - /api/v1/webhooks/{id}/test: - post: - operationId: testWebhookEndpoint - summary: Send a test ping to a webhook endpoint - description: | - Queues a test `ping` event delivery to the specified webhook endpoint. - The delivery is processed asynchronously. - tags: - - Webhooks - parameters: - - name: id - in: path - required: true - schema: - $ref: '#/components/schemas/WebhookId' - example: wh_a1b2c3d4e5f6g7h8 - x-rate-limit: - window: 60s - max: 10 - responses: - '202': - description: Test ping queued for delivery - content: - application/json: - schema: - type: object - properties: - delivery: - $ref: '#/components/schemas/WebhookDelivery' - example: - delivery: - id: dlv_x1y2z3 - endpoint_id: wh_a1b2c3d4e5f6g7h8 - event: ping - payload: - event: ping - timestamp: '2026-06-27T12:00:00.000Z' - status: pending - attempt_count: 0 - attempts: [] - next_retry_at: null - created_at: '2026-06-27T12:00:00.000Z' - updated_at: '2026-06-27T12:00:00.000Z' - '404': - $ref: '#/components/responses/NotFound' - '429': - $ref: '#/components/responses/RateLimited' - '500': - $ref: '#/components/responses/InternalError' - - /api/v1/indexer/status: - get: - operationId: getIndexerStatus - summary: Indexer service status (planned) - description: | - **⚠️ Planned — not yet implemented.** - Returns the current status and ledger height of the on-chain indexer service. - tags: - - Indexer - x-draft: true - responses: - '200': - description: Indexer status - content: - application/json: - schema: - type: object - properties: - status: - type: string - enum: - - syncing - - synced - - error - current_ledger: - type: integer - latest_ledger: - type: integer - last_indexed_at: - type: string - format: date-time - example: - status: synced - current_ledger: 52489123 - latest_ledger: 52489123 - last_indexed_at: '2026-06-27T12:00:00.000Z' - '503': - description: Indexer is not responding - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: Indexer unavailable - message: Indexer service is not responding - '500': - $ref: '#/components/responses/InternalError' - - /ws: - get: - operationId: websocketEndpoint - summary: Real-time event stream (planned) - description: | - **⚠️ Planned — not yet implemented.** - WebSocket endpoint for streaming real-time SmartDrop events - (price updates, airdrop status changes, webhook delivery logs). - tags: - - WebSocket - x-draft: true - x-websocket: true - responses: - '101': - description: WebSocket protocol upgrade (future) - '400': - $ref: '#/components/responses/ValidationError' - -components: - securitySchemes: - BearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: | - API key authentication. Provide your API key as a Bearer token in the - `Authorization` header. Keys are created via `POST /api/v1/keys` and - can be scoped to specific resources. - - parameters: - PageParam: - name: page - in: query - required: false - schema: - type: integer - minimum: 1 - default: 1 - description: 1-indexed page number. - LimitParam: - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - description: Results per page (max 100). - - schemas: - StellarAddress: - type: string - pattern: ^G[A-Z0-9]{55}$ - description: Stellar public key (ed25519) starting with G - example: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA - - StellarAddressNullable: - type: string - nullable: true - pattern: ^G[A-Z0-9]{55}$ - description: Stellar public key (ed25519) starting with G, or null for native assets - example: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA - - AssetCode: - type: string - minLength: 1 - maxLength: 12 - pattern: ^[A-Z0-9]+$ - description: Stellar asset code (1–12 uppercase alphanumeric) - example: USDC - - WebhookId: - type: string - pattern: ^wh_ - description: Webhook endpoint ID (prefixed with `wh_`) - example: wh_a1b2c3d4e5f6g7h8 - - PaginationMeta: - type: object - description: | - The canonical pagination envelope every list endpoint in this API - returns (see the README's "Pagination" section) — `GET - /api/v1/webhooks`, `/airdrops`, `/airdrops/:id/recipients`, - `/alerts`, `/airdrops/:id/onchain-recipients`, and - `/recipients/:address/claims`. `GET /api/v1/webhooks/:id/deliveries` - is intentionally exempt (limit-only, no page/offset concept). - properties: - page: - type: integer - example: 1 - limit: - type: integer - example: 20 - total: - type: integer - example: 42 - total_pages: - type: integer - example: 3 - has_next: - type: boolean - example: true - has_prev: - type: boolean - example: false - required: - - page - - limit - - total - - total_pages - - has_next - - has_prev - - HealthResponse: - type: object - properties: - status: - type: string - enum: - - ok - - degraded - description: Service health status - timestamp: - type: string - format: date-time - description: Current server time in ISO 8601 - redis_connected: - type: boolean - description: Whether Redis is connected - redis_unavailable: - type: boolean - description: Inverse of redis_connected (for legacy monitoring) - price_source_circuits: - type: array - description: Circuit-breaker state for each price source with a nonRetryable failure mode (e.g. an invalid API key) - items: - type: object - properties: - source: - type: string - description: Price source name (e.g. coingecko, coinmarketcap) - open: - type: boolean - description: Whether this source is currently circuit-broken and being skipped - openUntil: - type: string - format: date-time - nullable: true - description: When the circuit will next allow a retry, or null if closed - required: - - status - - timestamp - - redis_connected - - redis_unavailable - - PriceResponse: - type: object - properties: - asset_code: - $ref: '#/components/schemas/AssetCode' - issuer: - $ref: '#/components/schemas/StellarAddressNullable' - price_usd: - type: number - nullable: true - description: Current USD price (null if unavailable) - source: - type: string - enum: - - stellar_dex - - coingecko - - coinmarketcap - - aggregated - - unavailable - description: Primary data source - fetched_at: - type: string - format: date-time - description: When the price was last fetched - is_stale: - type: boolean - description: Whether the cached price exceeds the stale threshold - stale_warning: - type: string - nullable: true - description: Human-readable stale warning message - sources_attempted: - type: array - items: - type: string - description: List of data sources that were queried - redis_unavailable: - type: boolean - description: Whether Redis was unavailable during this request - required: - - asset_code - - issuer - - price_usd - - source - - fetched_at - - is_stale - - stale_warning - - sources_attempted - - redis_unavailable - - PriceNotFoundResponse: - allOf: - - $ref: '#/components/schemas/ErrorResponse' - - type: object - properties: - asset_code: - $ref: '#/components/schemas/AssetCode' - issuer: - $ref: '#/components/schemas/StellarAddressNullable' - price_usd: - type: number - nullable: true - source: - type: string - example: unavailable - fetched_at: - type: string - format: date-time - is_stale: - type: boolean - example: true - stale_warning: - type: string - sources_attempted: - type: array - items: - type: string - redis_unavailable: - type: boolean - - BatchPriceRequest: - type: object - properties: - assets: - type: array - minItems: 1 - maxItems: 100 - items: - type: object - properties: - asset_code: - $ref: '#/components/schemas/AssetCode' - issuer: - $ref: '#/components/schemas/StellarAddressNullable' - required: - - asset_code - required: - - assets - - CreateWebhookRequest: - type: object - properties: - url: - type: string - format: uri - description: Subscriber endpoint URL (HTTP or HTTPS) - example: https://example.com/webhooks/smartdrop - events: - type: array - minItems: 1 - items: - $ref: '#/components/schemas/WebhookEvent' - description: Events the endpoint wishes to subscribe to - secret: - type: string - minLength: 8 - description: Shared secret used for HMAC-SHA256 signature verification - example: whsec_myverys3cretkey - required: - - url - - events - - secret - - WebhookEndpoint: - type: object - properties: - id: - $ref: '#/components/schemas/WebhookId' - url: - type: string - format: uri - events: - type: array - items: - $ref: '#/components/schemas/WebhookEvent' - active: - type: boolean - description: Whether the endpoint is currently active - secret_preview: - type: string - nullable: true - description: First 4 and last 4 characters of the secret - created_at: - type: string - format: date-time - updated_at: - type: string - format: date-time - required: - - id - - url - - events - - active - - secret_preview - - created_at - - updated_at - - WebhookEvent: - type: string - enum: - - airdrop.created - - airdrop.executing - - airdrop.completed - - airdrop.failed - - recipient.claimed - - ping - description: SmartDrop lifecycle events - - WebhookDelivery: - type: object - properties: - id: - type: string - pattern: ^dlv_ - endpoint_id: - $ref: '#/components/schemas/WebhookId' - event: - $ref: '#/components/schemas/WebhookEvent' - payload: - type: object - status: - type: string - enum: - - pending - - delivered - - failed - - dead_letter - attempt_count: - type: integer - minimum: 0 - attempts: - type: array - items: - $ref: '#/components/schemas/DeliveryAttempt' - next_retry_at: - type: string - format: date-time - nullable: true - created_at: - type: string - format: date-time - updated_at: - type: string - format: date-time - required: - - id - - endpoint_id - - event - - payload - - status - - attempt_count - - attempts - - created_at - - updated_at - - DeliveryAttempt: - type: object - properties: - attempt: - type: integer - description: Attempt number (1-based) - ok: - type: boolean - status: - type: string - enum: - - delivered - - failed - response_code: - type: integer - nullable: true - error: - type: string - nullable: true - duration_ms: - type: integer - nullable: true - created_at: - type: string - format: date-time - next_retry_at: - type: string - format: date-time - nullable: true - required: - - attempt - - ok - - status - - response_code - - error - - duration_ms - - created_at - - next_retry_at - - ErrorResponse: - type: object - properties: - error: - type: string - description: Machine-readable error type - message: - type: string - description: Human-readable error description - required: - - error - - message - - ValidationErrorDetail: - type: object - properties: - error: - type: string - example: Validation error - message: - type: string - example: url must be a valid HTTP or HTTPS URL - details: - type: array - items: - type: object - properties: - field: - type: string - issue: - type: string - required: - - error - - message - - RateLimitInfo: - type: object - properties: - error: - type: string - example: Too many requests - message: - type: string - example: Rate limit exceeded. Try again in 42 seconds. - retry_after_seconds: - type: integer - required: - - error - - message - - responses: - ValidationError: - description: Request validation failed - content: - application/json: - schema: - $ref: '#/components/schemas/ValidationErrorDetail' - Unauthorized: - description: Missing or invalid authentication - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: Missing or invalid API key - message: Missing or invalid API key - NotFound: - description: Resource not found - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: Webhook endpoint not found - message: Webhook endpoint not found - UnprocessableEntity: - description: Request body is semantically invalid - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: Unprocessable entity - message: One or more field values are invalid - RateLimited: - description: Rate limit exceeded - content: - application/json: - schema: - $ref: '#/components/schemas/RateLimitInfo' - example: - error: Too many requests - message: Rate limit exceeded. Try again in 42 seconds. - retry_after_seconds: 42 - headers: - Retry-After: - schema: - type: integer - description: Seconds to wait before retrying - X-RateLimit-Limit: - schema: - type: integer - description: Maximum requests per window - X-RateLimit-Remaining: - schema: - type: integer - description: Remaining requests in current window - X-RateLimit-Reset: - schema: - type: integer - description: Unix timestamp when the window resets - InternalError: - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: Internal server error - message: An unexpected error occurred diff --git a/package-lock.json b/package-lock.json index c8ef62c..5eff3b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,40 +1,205 @@ { - "name": "smartdrop-backend", - "version": "0.1.0", + "name": "backend", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "smartdrop-backend", - "version": "0.1.0", - "dependencies": { - "axios": "^1.7.0", - "cors": "^2.8.5", - "csv-parser": "^3.2.1", - "dotenv": "^16.4.5", - "envalid": "^8.2.0", - "express": "^4.21.0", - "helmet": "^8.2.0", - "ioredis": "^5.4.1", - "knex": "^3.3.0", - "multer": "^2.2.0", - "node-cron": "^3.0.3", - "pg": "^8.22.0", - "stellar-sdk": "^11.3.0", - "swagger-ui-express": "^5.0.1", - "winston": "^3.14.0", - "winston-daily-rotate-file": "^5.0.0", - "ws": "^8.21.0", - "yamljs": "^0.3.0", - "zod": "^4.4.3" + "name": "backend", + "version": "0.0.1", + "license": "UNLICENSED", + "dependencies": { + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.0.1", + "@prisma/client": "6.19.3", + "@stellar/stellar-sdk": "^16.2.0", + "bcrypt": "^6.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "helmet": "^8.3.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" }, "devDependencies": { - "@redocly/cli": "^2.35.1", - "jest": "^29.7.0", - "supertest": "^7.2.2" + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/bcrypt": "^6.0.0", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^24.0.0", + "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^7.0.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^17.11.0", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "prisma": "6.19.3", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@angular-devkit/core": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", + "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@angular-devkit/core/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.27.tgz", + "integrity": "sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.27.tgz", + "integrity": "sha512-wHYH6SVXVykhLzovUHtYor3Nl4SpIiITi7r9DQDaKYUD4hpRBx25W6N9eGuakT9Vd5tV/x6wmvQFWQZQwFB7eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "@angular-devkit/schematics": "19.2.27", + "@inquirer/prompts": "7.3.2", + "ansi-colors": "4.1.3", + "symbol-observable": "4.0.0", + "yargs-parser": "21.1.1" + }, + "bin": { + "schematics": "bin/schematics.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" }, "engines": { - "node": ">=20.9.0" + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" } }, "node_modules/@babel/code-frame": { @@ -93,31 +258,16 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/@babel/core/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/generator": { "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", @@ -152,6 +302,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -537,31 +697,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@babel/traverse/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/types": { "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", @@ -583,4019 +718,8387 @@ "dev": true, "license": "MIT" }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">=0.1.90" } }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", - "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, "license": "MIT", "dependencies": { - "@so-ric/colorspace": "^1.1.6", - "enabled": "2.0.x", - "kuler": "^2.0.0" + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@ioredis/commands": { + "node_modules/@emnapi/core": { "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", - "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", - "license": "MIT" - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, - "license": "ISC", + "license": "MIT", + "optional": true, "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "tslib": "^2.4.0" } }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "funding": { + "url": "https://opencollective.com/eslint" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, + "license": "Apache-2.0", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "jest-get-type": "^29.6.3" + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "@eslint/core": "^0.17.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", "dev": true, "license": "MIT", "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/eslint" } }, - "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==", + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, + "license": "Apache-2.0", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" + "@humanfs/types": "^0.15.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.18.0" } }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.18.0" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=6.0.0" + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "engines": { + "node": ">=18" } }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", "dev": true, "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, "engines": { - "node": "^14.21.3 || >=16" + "node": ">=18" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", "dev": true, "license": "MIT", "dependencies": { - "@noble/hashes": "^1.1.5" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@redocly/cli": { - "version": "2.46.1", - "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.46.1.tgz", - "integrity": "sha512-FSUSq2FU8VN7DmTobTmq7zb3zPnDkRKSMd3n38D62JFJUUZKnLMQtn7BUhsD2dCFH2WgF05I3fbN7QHDQyCBeA==", + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", "dev": true, "license": "MIT", - "bin": { - "openapi": "bin/cli.js", - "redocly": "bin/cli.js" + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" }, "engines": { - "node": ">=22.12.0 || >=20.19.0 <21.0.0", - "npm": ">=10" + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@scarf/scarf": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", - "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", - "hasInstallScript": true, - "license": "Apache-2.0" - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.12", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", - "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "type-detect": "4.0.8" + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@so-ric/colorspace": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", - "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", "license": "MIT", "dependencies": { - "color": "^5.0.2", - "text-hex": "1.0.x" - } - }, - "node_modules/@stellar/js-xdr": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", - "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", - "license": "Apache-2.0" - }, - "node_modules/@stellar/stellar-base": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-11.1.0.tgz", - "integrity": "sha512-nMg7QSpFqCZFq3Je/lG12+DY18y01QHRNyCxvjM8i4myS9tPRMDq7zqGcd215BGbCJxenckiOW45YJjQjzdcMQ==", - "deprecated": "This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.", - "license": "Apache-2.0", - "dependencies": { - "@stellar/js-xdr": "^3.1.1", - "base32.js": "^0.1.0", - "bignumber.js": "^9.1.2", - "buffer": "^6.0.3", - "sha.js": "^2.3.6", - "tweetnacl": "^1.0.3" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, - "optionalDependencies": { - "sodium-native": "^4.1.1" + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" + "engines": { + "node": ">=18" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", "dev": true, "license": "MIT", "dependencies": { - "@types/istanbul-lib-coverage": "*" + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", "dev": true, "license": "MIT", "dependencies": { - "@types/istanbul-lib-report": "*" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/triple-beam": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", - "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", - "license": "MIT" + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", "dev": true, "license": "MIT", "dependencies": { - "@types/yargs-parser": "*" + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", "dev": true, - "license": "MIT" - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", "dependencies": { - "debug": "4" + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, "engines": { - "node": ">= 6.0.0" + "node": ">=12" } }, - "node_modules/agent-base/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.3" + "engines": { + "node": ">=12" }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.0" + "node": ">=12" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/agent-base/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, "license": "MIT" }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { - "type-fest": "^0.21.3" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", "dev": true, "license": "ISC", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" }, "engines": { - "node": ">= 8" + "node": ">=8" } }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT" - }, - "node_modules/argparse": { + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, - "license": "MIT" - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "license": "MIT", "dependencies": { - "possible-typed-array-names": "^1.0.0" + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/axios": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", - "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.6", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" + "p-locate": "^4.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" + "node": ">=8" } }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" + "p-try": "^2.0.0" }, "engines": { - "node": ">=8" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" + "p-limit": "^2.2.0" }, "engines": { "node": ">=8" } }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" + "engines": { + "node": ">=8" } }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "node_modules/@jest/console": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/bare-addon-resolve": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.1.tgz", - "integrity": "sha512-F/SD2du8keuYSb4xipnGz5j2E6yhNdHA8ZVxtHae6h2uOrpBIjjbhXvjzKZbr5XUOzqBzh/i8GVFycj2DlFQIA==", - "license": "Apache-2.0", - "optional": true, + "node_modules/@jest/core": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "dev": true, + "license": "MIT", "dependencies": { - "bare-module-resolve": "^1.10.0", - "bare-semver": "^1.0.0" - }, - "peerDependencies": { - "bare-url": "*" + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", + "slash": "^3.0.0" }, - "peerDependenciesMeta": { - "bare-url": { - "optional": true - } - } - }, - "node_modules/bare-module-resolve": { - "version": "1.12.4", - "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.4.tgz", - "integrity": "sha512-xcfgg2u7HqgJiBmah71O9vvdFAgHCvkqC/WSC2O7Bbgosoc1eC/BWe/6IDJ4OsfKlkxuvC/TDWXC+oH5yeW8mA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-semver": "^1.0.0" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "bare-url": "*" + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "peerDependenciesMeta": { - "bare-url": { + "node-notifier": { "optional": true } } }, - "node_modules/bare-semver": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.1.0.tgz", - "integrity": "sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==", - "license": "Apache-2.0", - "optional": true - }, - "node_modules/base32.js": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", - "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.12.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", - "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" }, "engines": { - "node": ">=6.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "node_modules/@jest/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "dev": true, "license": "MIT", + "dependencies": { + "expect": "30.4.1", + "jest-snapshot": "30.4.1" + }, "engines": { - "node": "*" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "@jest/get-type": "30.1.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", "dev": true, "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" - }, - "bin": { - "browserslist": "cli.js" + "@types/node": "*", + "jest-regex-util": "30.4.0" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "node_modules/@jest/reporters": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "node-int64": "^0.4.0" + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, "license": "MIT", "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" + "balanced-match": "^1.0.0" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "node_modules/@jest/reporters/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", "dependencies": { - "streamsearch": "^1.1.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": ">=10.16.0" + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", + "node_modules/@jest/reporters/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/@jest/reporters/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, "engines": { - "node": ">= 0.8" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "license": "MIT", + "node_modules/@jest/reporters/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=16 || 14 >=14.18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "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==", + "node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "node_modules/@jest/snapshot-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/@jest/test-result": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "node_modules/@jest/test-sequencer": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "slash": "^3.0.0" + }, "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001809", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", - "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@jest/transform": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@babel/core": "^7.27.4", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/cluster-key-slot": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", - "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", - "license": "Apache-2.0", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=6.0.0" } }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" } }, - "node_modules/collect-v8-coverage": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", - "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, - "node_modules/color": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", - "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^3.1.3", - "color-string": "^2.1.3" - }, + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "color-name": "~1.1.4" + "@tybys/wasm-util": "^0.10.3" }, "engines": { - "node": ">=7.0.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@nestjs/cli": { + "version": "11.0.24", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.24.tgz", + "integrity": "sha512-aIHxQLSYtXShifA3zwWIeznEsZnNa3Iz2QRykFj+sl9IcbERBHr5nH87FRgywM+He3NxoF5WazHfR8FsmVeWxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "@angular-devkit/schematics": "19.2.27", + "@angular-devkit/schematics-cli": "19.2.27", + "@inquirer/prompts": "7.10.1", + "@nestjs/schematics": "^11.0.1", + "ansis": "4.2.0", + "chokidar": "4.0.3", + "cli-table3": "0.6.5", + "commander": "4.1.1", + "fork-ts-checker-webpack-plugin": "9.1.0", + "glob": "13.0.6", + "node-emoji": "1.11.0", + "ora": "5.4.1", + "tsconfig-paths": "4.2.0", + "tsconfig-paths-webpack-plugin": "4.2.0", + "typescript": "5.9.3", + "webpack": "5.106.2", + "webpack-node-externals": "3.0.0" + }, + "bin": { + "nest": "bin/nest.js" + }, + "engines": { + "node": ">= 20.11" + }, + "peerDependencies": { + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", + "@swc/core": "^1.3.62" + }, + "peerDependenciesMeta": { + "@swc/cli": { + "optional": true + }, + "@swc/core": { + "optional": true + } } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/@nestjs/cli/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, - "license": "MIT" - }, - "node_modules/color-string": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", - "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", "license": "MIT", "dependencies": { - "color-name": "^2.0.0" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, - "engines": { - "node": ">=18" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/color-string/node_modules/color-name": { + "node_modules/@nestjs/cli/node_modules/ajv-formats": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", - "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=12.20" + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/color/node_modules/color-convert": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", - "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "node_modules/@nestjs/cli/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, "license": "MIT", "dependencies": { - "color-name": "^2.0.0" + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/@nestjs/cli/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" }, "engines": { - "node": ">=14.6" + "node": ">=8.0.0" } }, - "node_modules/color/node_modules/color-name": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", - "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", - "license": "MIT", + "node_modules/@nestjs/cli/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=12.20" + "node": ">=4.0" } }, - "node_modules/colorette": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", - "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", + "node_modules/@nestjs/cli/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, "license": "MIT" }, - "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==", + "node_modules/@nestjs/cli/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, "license": "MIT", "dependencies": { - "delayed-stream": "~1.0.0" + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "license": "MIT", + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@nestjs/cli/node_modules/webpack": { + "version": "5.106.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.1", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, "engines": { - "node": ">=14" + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } } }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, + "node_modules/@nestjs/common": { + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.28.tgz", + "integrity": "sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ==", "license": "MIT", + "dependencies": { + "file-type": "21.3.4", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], + "node_modules/@nestjs/config": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", "license": "MIT", "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" } }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "node_modules/@nestjs/core": { + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.28.tgz", + "integrity": "sha512-06m63xIRj8+l8uOeh/8LnYupGubkyu4f+bPKIadaSui6vK9KpXgoz7HveT1yOVLcEt0M0oCOEW5EuEXZkEmBBQ==", "license": "MIT", "dependencies": { - "safe-buffer": "5.2.1" + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" }, "engines": { - "node": ">= 0.6" + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } } }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", "license": "MIT", - "engines": { - "node": ">= 0.6" + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" } }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "node_modules/@nestjs/platform-express": { + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.28.tgz", + "integrity": "sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA==", "license": "MIT", "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.2.0", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" } }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "node_modules/@nestjs/schematics": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", + "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "comment-json": "5.0.0", + "jsonc-parser": "3.3.1", + "pluralize": "8.0.0" }, - "bin": { - "create-jest": "bin/create-jest.js" + "peerDependencies": { + "prettier": "^3.0.0", + "typescript": ">=4.8.2" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "peerDependenciesMeta": { + "prettier": { + "optional": true + } } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" }, "engines": { - "node": ">= 8" - } - }, - "node_modules/csv-parser": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.1.tgz", - "integrity": "sha512-v8RPMSglouR9od735SnwSxLBbCJqEPSbgm1R5qfr8yIiMUCEFjox56kRZid0SvgHJEkxeIEu3+a9QS3YRh7CuA==", - "license": "MIT", - "bin": { - "csv-parser": "bin/csv-parser" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/dedent": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", - "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", - "dev": true, - "license": "MIT", "peerDependencies": { - "babel-plugin-macros": "^3.1.0" + "chokidar": "^4.0.0" }, "peerDependenciesMeta": { - "babel-plugin-macros": { + "chokidar": { "optional": true } } }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", "dev": true, "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, "engines": { - "node": ">=0.10.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "node_modules/@nestjs/schematics/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/delayed-stream": { + "node_modules/@nestjs/schematics/node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "node_modules/@nestjs/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, "license": "Apache-2.0", - "engines": { - "node": ">=0.10" + "dependencies": { + "tslib": "^2.1.0" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "node_modules/@nestjs/testing": { + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.28.tgz", + "integrity": "sha512-B+VgRxeLaH7jkOMgAyUP3N3rpFlisQ7JRxixRbgHvG6a0VgKbbkNSofKExexCgKmQQak80undb3+2kE1lUBmRQ==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + } } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "node_modules/@noble/ed25519": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz", + "integrity": "sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==", "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" + "@noble/hashes": "^1.1.5" } }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "node_modules/@paralleldrive/cuid2/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "dev": true, "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" + "node": "^14.21.3 || >=16" }, "funding": { - "url": "https://dotenvx.com" + "url": "https://paulmillr.com/funding/" } }, - "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==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, + "optional": true, "engines": { - "node": ">= 0.4" + "node": ">=14" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.407", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz", - "integrity": "sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" + "url": "https://opencollective.com/pkgr" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/enabled": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", "engines": { - "node": ">= 0.8" + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } } }, - "node_modules/envalid": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/envalid/-/envalid-8.2.0.tgz", - "integrity": "sha512-CkPvea95dwMYE1wnKX5mQXkOpiMs9O+ncv8NqZy+gW7FuzpUp06KTdUHb18xFG8CqQHmfmRqGLF5DuGaBWNrSw==", - "license": "MIT", + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=18" + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" } }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "is-arrayish": "^0.2.1" + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@stellar/js-xdr": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", + "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", + "license": "Apache-2.0", "engines": { - "node": ">= 0.4" + "node": ">=20.0.0", + "pnpm": ">=9.0.0" } }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", + "node_modules/@stellar/stellar-sdk": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.2.0.tgz", + "integrity": "sha512-FV/Rm11QvrFzR5X9fIfb6Pg30KyYyrRkNezELGFmYDAYrzoPTjqo7vklnEPd2HEontzjJmZizWk8CjyIh5vp0w==", + "license": "Apache-2.0", "dependencies": { - "es-errors": "^1.3.0" + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", + "@stellar/js-xdr": "4.0.0", + "axios": "1.18.0", + "base32.js": "^0.1.0", + "bignumber.js": "^11.1.4", + "buffer": "^6.0.3", + "commander": "^14.0.3", + "eventsource": "^4.1.0", + "feaxios": "^0.0.23", + "smol-toml": "^1.6.1", + "uint8array-extras": "^1.5.0" + }, + "bin": { + "stellar-js": "bin/stellar-js" }, "engines": { - "node": ">= 0.4" + "node": ">=22.0.0" } }, - "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==", + "node_modules/@stellar/stellar-sdk/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "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": ">=20" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/escape-html": { + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, "license": "MIT" }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/esm": { - "version": "3.2.25", - "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", - "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "@babel/types": "^7.0.0" } }, - "node_modules/eventsource": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=12.0.0" + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" } }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "@babel/types": "^7.28.2" } }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", "dev": true, - "engines": { - "node": ">= 0.8.0" + "license": "MIT", + "dependencies": { + "@types/node": "*" } }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "@types/connect": "*", + "@types/node": "*" } }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", "dev": true, "license": "MIT" }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "bser": "2.1.1" + "@types/eslint": "*", + "@types/estree": "*" } }, - "node_modules/fecha": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", - "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, "license": "MIT" }, - "node_modules/file-stream-rotator": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", - "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, "license": "MIT", "dependencies": { - "moment": "^2.29.1" + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", "dev": true, "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" } }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" + "@types/istanbul-lib-coverage": "*" } }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" + "@types/istanbul-lib-report": "*" } }, - "node_modules/fn.name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", - "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", - "license": "MIT" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@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==", "license": "MIT", "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@types/ms": "*", + "@types/node": "*" } }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "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==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" + "undici-types": "~7.18.0" } }, - "node_modules/formidable": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", "dev": true, "license": "MIT", "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" + "@types/express": "*" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "@types/express": "*", + "@types/passport": "*" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "dependencies": { + "@types/node": "*" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6.9.0" + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" } }, - "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==", + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8.0.0" + "node": ">= 4" } }, - "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==", + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, "engines": { - "node": ">=10" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/getopts": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", - "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==", - "license": "MIT" - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" }, "engines": { - "node": "*" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "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==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "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==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, "license": "MIT", "dependencies": { - "has-symbols": "^1.0.3" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">= 0.4" + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/helmet": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", - "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/EvanHahn" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/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": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/babel-jest": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.4.1", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.4.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.4.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.11.tgz", + "integrity": "sha512-/yImnXwyTvgMkhgekLHok/Rx5vO6E0BmStWlSqKWMVm2a2ITuZ1Tn+9bgLS+gZRdZmWtd8nxuhHpdmCUOWsTQQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bignumber.js": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", + "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "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/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", + "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "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==", + "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", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/comment-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "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", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "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==", + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.13" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/eventsource": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.0.tgz", + "integrity": "sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-check/node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/feaxios": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", + "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", + "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^4.0.1", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", + "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "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-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-retry-allowed": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", + "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "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/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", + "import-local": "^3.2.0", + "jest-cli": "30.4.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0", + "pretty-format": "30.4.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "parse-json": "^5.2.0", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/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/jest-config/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "jest-util": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "node_modules/jest-runtime/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", + "license": "ISC", "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": ">= 0.8" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", + "node_modules/jest-runtime/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/jest-runtime/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", "dependencies": { - "agent-base": "6", - "debug": "4" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">= 6" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", + "node_modules/jest-runtime/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "ms": "^2.1.3" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=6.0" + "node": ">=16 || 14 >=14.18" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "node_modules/jest-snapshot": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, "engines": { - "node": ">=10.17.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" }, "engines": { - "node": ">=0.10.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/jest-watcher": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/interpret": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", - "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", - "license": "MIT", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.4.1", + "string-length": "^4.0.2" + }, "engines": { - "node": ">= 0.10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/ioredis": { - "version": "5.11.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", - "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "node_modules/jest-worker": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "dev": true, "license": "MIT", "dependencies": { - "@ioredis/commands": "1.10.0", - "cluster-key-slot": "1.1.1", - "debug": "4.4.3", - "denque": "2.1.0", - "redis-errors": "1.2.0", - "redis-parser": "3.0.0", - "standard-as-callback": "2.1.0" + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" }, "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/ioredis/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=6.0" + "node": ">=10" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/ioredis/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, "license": "MIT", - "engines": { - "node": ">= 0.10" + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "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==", "dev": true, "license": "MIT" }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "argparse": "^2.0.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" + "bin": { + "jsesc": "bin/jsesc" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, "engines": { "node": ">=6" } }, - "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/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } + "license": "MIT" }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "universalify": "^2.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "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": { - "which-typed-array": "^1.1.16" + "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": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12", + "npm": ">=6" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "license": "MIT" + "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/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/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/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" } }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=6" } }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": ">=10" + "node": ">= 0.8.0" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "node_modules/libphonenumber-js": { + "version": "1.13.10", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.10.tgz", + "integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, + "license": "MIT" + }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=13.2.0" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/istanbul-lib-source-maps/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "p-locate": "^5.0.0" }, "engines": { - "node": ">=6.0" + "node": ">=10" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/istanbul-lib-source-maps/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "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.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true, "license": "MIT" }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "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/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - }, - "bin": { - "jest": "bin/jest.js" + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "node": ">=10" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "yallist": "^3.0.2" } }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" + "semver": "^7.5.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "node": ">=10" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" + "node": ">= 0.8" }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", "dev": true, - "license": "MIT", + "license": "Unlicense", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "fs-monkey": "^1.0.4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 4.0.0" } }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dev": true, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", - "dependencies": { - "detect-newline": "^3.0.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "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/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.6" } }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "bin": { + "mime": "cli.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=4.0.0" } }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.6" } }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" + "mime-db": "^1.54.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18" }, - "optionalDependencies": { - "fsevents": "^2.3.2" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=6" } }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "brace-expansion": "^1.1.7" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "*" } }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "jest-resolve": "*" + "webpack": "^5.1.0" }, "peerDependenciesMeta": { - "jest-resolve": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { "optional": true } } }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "node_modules/minimizer-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "node_modules/minimizer-webpack-plugin/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" + "ajv": "^8.0.0" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "node_modules/minimizer-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" + "fast-deep-equal": "^3.1.3" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "peerDependencies": { + "ajv": "^8.8.2" } }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "node_modules/minimizer-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 10.13.0" } }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "node_modules/minimizer-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } + "peer": true }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "node_modules/minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "node_modules/minimizer-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.6" } - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "mime-db": "1.52.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.6" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "media-typer": "0.3.0", + "mime-types": "~2.1.24" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "node": ">= 0.6" } }, - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", - "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, "license": "MIT", "bin": { - "jsesc": "bin/jsesc" + "napi-postinstall": "lib/cli.js" }, "engines": { - "node": ">=6" + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, "engines": { - "node": ">=6" + "node": ">= 0.6" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "license": "MIT" }, - "node_modules/knex": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/knex/-/knex-3.3.0.tgz", - "integrity": "sha512-LgWl031hNuLv9Lhxdd9093zULa2aNcoP04Bk29e5NidgRcEr/T0rAr2WYi4BhjTiCDoQiRU56meUE5rppLKZzQ==", + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "8.9.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz", + "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==", "license": "MIT", - "dependencies": { - "colorette": "2.0.19", - "commander": "^10.0.0", - "debug": "4.3.4", - "escalade": "^3.1.1", - "esm": "^3.2.25", - "get-package-type": "^0.1.0", - "getopts": "2.3.0", - "interpret": "^2.2.0", - "lodash": "^4.18.1", - "pg-connection-string": "2.6.2", - "rechoir": "^0.8.0", - "resolve-from": "^5.0.0", - "tarn": "^3.1.0", - "tildify": "2.0.0" - }, - "bin": { - "knex": "bin/cli.js" - }, "engines": { - "node": ">=16" - }, - "peerDependencies": { - "pg-query-stream": "^4.14.0" - }, - "peerDependenciesMeta": { - "better-sqlite3": { - "optional": true - }, - "mariadb": { - "optional": true - }, - "mysql": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "pg-native": { - "optional": true - }, - "pg-query-stream": { - "optional": true - }, - "sqlite3": { - "optional": true - }, - "tedious": { - "optional": true - } + "node": "^18 || ^20 || >= 21" } }, - "node_modules/knex/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "node_modules/node-emoji": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "lodash": "^4.17.21" } }, - "node_modules/knex/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, "license": "MIT" }, - "node_modules/kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, "license": "MIT" }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "path-key": "^3.0.0" }, "engines": { "node": ">=8" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/logform": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", - "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "node_modules/nypm": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz", + "integrity": "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==", + "devOptional": true, "license": "MIT", "dependencies": { - "@colors/colors": "1.6.0", - "@types/triple-beam": "^1.3.2", - "fecha": "^4.2.0", - "ms": "^2.1.1", - "safe-stable-stringify": "^2.3.1", - "triple-beam": "^1.3.0" + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.2.4" + }, + "bin": { + "nypm": "dist/cli.mjs" }, "engines": { - "node": ">= 12.0.0" + "node": ">=18" } }, - "node_modules/logform/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, "license": "MIT" }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" }, - "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==", + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, "engines": { - "node": ">= 0.4" + "node": ">= 0.8" } }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" } }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, - "license": "MIT" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, "engines": { - "node": ">= 0.6" + "node": ">= 0.8.0" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "dev": true, "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" }, "engines": { - "node": ">=8.6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", - "bin": { - "mime": "cli.js" + "dependencies": { + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">=4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, "engines": { - "node": ">= 0.6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, "engines": { - "node": ">= 0.6" + "node": ">=6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, "engines": { "node": ">=6" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" }, "engines": { - "node": "*" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", "engines": { - "node": "*" + "node": ">= 0.8" } }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/multer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", - "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.6.0", - "concat-stream": "^2.0.0", - "type-is": "^1.6.18" + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" }, "engines": { - "node": ">= 10.16.0" + "node": ">= 0.4.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" } }, - "node_modules/node-cron": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", - "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", - "license": "ISC", - "dependencies": { - "uuid": "8.3.2" - }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", "engines": { - "node": ">=6.0.0" + "node": ">= 0.4.0" } }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.53", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", - "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "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", - "dependencies": { - "path-key": "^3.0.0" - }, "engines": { "node": ">=8" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, "engines": { - "node": ">=0.10.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "license": "MIT", + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">= 6" + "node": "20 || >=22" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, "engines": { - "node": ">= 0.8" + "node": ">=8" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, - "node_modules/one-time": { + "node_modules/perfect-debounce": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, "license": "MIT", - "dependencies": { - "fn.name": "1.x.x" + "engines": { + "node": ">= 6" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "find-up": "^4.0.0" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" + "p-locate": "^4.1.0" }, "engines": { "node": ">=8" } }, - "node_modules/p-locate/node_modules/p-limit": { + "node_modules/pkg-dir/node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", @@ -4611,1278 +9114,1573 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.8.0" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, "engines": { - "node": ">=8" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/path-is-absolute": { + "node_modules/prettier-linter-helpers": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">=6.0.0" } }, - "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==", + "node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/pg": { - "version": "8.23.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", - "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "node_modules/pretty-format/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/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "pg-connection-string": "^2.14.0", - "pg-pool": "^3.14.0", - "pg-protocol": "^1.16.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" }, - "engines": { - "node": ">= 16.0.0" + "bin": { + "prisma": "build/index.js" }, - "optionalDependencies": { - "pg-cloudflare": "^1.4.0" + "engines": { + "node": ">=18.18" }, "peerDependencies": { - "pg-native": ">=3.0.1" + "typescript": ">=5.1.0" }, "peerDependenciesMeta": { - "pg-native": { + "typescript": { "optional": true } } }, - "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", - "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==", - "license": "MIT" + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", "engines": { - "node": ">=4.0.0" + "node": ">=10" } }, - "node_modules/pg-pool": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "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", - "peerDependencies": { - "pg": ">=8.0" + "engines": { + "node": ">=6" } }, - "node_modules/pg-protocol": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", - "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], "license": "MIT" }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">=4" + "node": ">= 0.10" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" } }, - "node_modules/pg/node_modules/pg-connection-string": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "node_modules/react-is-18": { + "name": "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/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { - "split2": "^4.1.0" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=0.10.0" } }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=0.10.0" } }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", "dev": true, "license": "MIT", "dependencies": { - "find-up": "^4.0.0" + "resolve-from": "^5.0.0" }, "engines": { "node": ">=8" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=8" } }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" } }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", "dependencies": { - "xtend": "^4.0.0" + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 18" } }, - "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==", + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/pretty-format/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", + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" }, "engines": { - "node": ">= 6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.10" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "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, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" }, "engines": { - "node": ">=0.6" + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, "engines": { - "node": ">= 0.6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/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": ">= 6" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rechoir": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", - "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, "license": "MIT", - "dependencies": { - "resolve": "^1.20.0" - }, "engines": { - "node": ">= 10.13.0" + "node": ">=8" } }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", + "node_modules/smol-toml": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz", + "integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==", + "license": "BSD-3-Clause", "engines": { - "node": ">=4" + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" } }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "dependencies": { - "redis-errors": "^1.0.0" - }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", "engines": { - "node": ">=4" + "node": ">= 8" } }, - "node_modules/require-addon": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz", - "integrity": "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==", - "license": "Apache-2.0", - "optional": true, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", "dependencies": { - "bare-addon-resolve": "^1.3.0" - }, - "engines": { - "bare": ">=1.10.0" + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "escape-string-regexp": "^2.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10" } }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "dev": true, "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, "engines": { "node": ">=8" } }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.8" } }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, - "license": "MIT", + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", "engines": { - "node": ">=10" + "node": ">=10.0.0" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "safe-buffer": "~5.2.0" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">=10" } }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">=8" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=8" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/sha.js": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", - "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", - "license": "(MIT AND BSD-3-Clause)", + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.0" - }, - "bin": { - "sha.js": "bin.js" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">= 0.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "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==", + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "ansi-regex": "^5.0.1" }, "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==", + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" + "@tokenizer/token": "^0.3.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=14.18.0" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", "dev": true, "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, "engines": { - "node": ">=8" + "node": ">=14.18.0" } }, - "node_modules/sodium-native": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", - "integrity": "sha512-OnxSlN3uyY8D0EsLHpmm2HOFmKddQVvEMmsakCrXUzSd8kjjbzL413t4ZNF3n0UxSwNgwTyUvkmZHTfuCeiYSw==", + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "require-addon": "^1.1.0" + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=0.10" } }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", "dev": true, "license": "MIT", "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", + "@pkgr/core": "^0.3.6" + }, "engines": { - "node": ">= 10.x" + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, - "node_modules/stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, "license": "MIT", "engines": { - "node": "*" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "node_modules/terser": { + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "escape-string-regexp": "^2.0.0" + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" }, "engines": { "node": ">=10" } }, - "node_modules/standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "dev": true, "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, "engines": { - "node": ">= 0.8" + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } } }, - "node_modules/stellar-sdk": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/stellar-sdk/-/stellar-sdk-11.3.0.tgz", - "integrity": "sha512-xOp2zpQm5TIbgJi7wJhAmJh+Uy0ew5GbGtj1kZv6HEWHgSvW95xYMxGaw6MWM9r2YPUSQySboE6JwDc9jdx53A==", - "deprecated": "⚠️ This package has moved to @stellar/stellar-sdk! 🚚", - "license": "Apache-2.0", + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", "dependencies": { - "@stellar/stellar-base": "^11.0.1", - "axios": "^1.6.8", - "bignumber.js": "^9.1.2", - "eventsource": "^2.0.2", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.1" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "node_modules/terser-webpack-plugin/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, "license": "MIT", "dependencies": { - "safe-buffer": "~5.2.0" + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" + "fast-deep-equal": "^3.1.3" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "ajv": "^8.8.2" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/terser-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" }, "engines": { - "node": ">=8" + "node": ">= 10.13.0" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">=8" + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "node_modules/terser-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, "engines": { - "node": ">=8" + "node": "*" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/superagent": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", - "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", - "dev": true, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "devOptional": true, "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.1", - "cookiejar": "^2.1.4", - "debug": "^4.3.7", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.5", - "formidable": "^3.5.4", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.14.1" - }, "engines": { - "node": ">=14.18.0" + "node": ">=18" } }, - "node_modules/superagent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=6.0" + "node": ">=12.0.0" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/superagent/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", - "bin": { - "mime": "cli.js" - }, "engines": { - "node": ">=4.0.0" + "node": ">=0.6" } }, - "node_modules/superagent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/supertest": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", - "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", - "dev": true, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", "license": "MIT", "dependencies": { - "cookie-signature": "^1.2.2", - "methods": "^1.1.2", - "superagent": "^10.3.0" + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" }, "engines": { - "node": ">=14.18.0" + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/supertest/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.6.0" + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/ts-jest": { + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.5", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">= 0.4" + "node": ">=16" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/swagger-ui-dist": { - "version": "5.32.13", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz", - "integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==", - "license": "Apache-2.0", + "node_modules/ts-loader": { + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.2.tgz", + "integrity": "sha512-R4iuczmtgxvtuI556s+hTZ6/7Ee03VCAk/l/M8LY1OAsUgB7YydsCxkgq9D9pKRaD7GJqUi2u8fp9zZP/ufjKA==", + "dev": true, + "license": "MIT", "dependencies": { - "@scarf/scarf": "=1.4.0" + "chalk": "^4.1.0", + "picomatch": "^4.0.0", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "loader-utils": "*", + "typescript": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } } }, - "node_modules/swagger-ui-express": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", - "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, "license": "MIT", "dependencies": { - "swagger-ui-dist": ">=5.0.0" + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" }, - "engines": { - "node": ">= v0.10.32" + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" }, "peerDependencies": { - "express": ">=4.0.0 || >=5.0.0-beta" + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } } }, - "node_modules/tarn": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.1.2.tgz", - "integrity": "sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg==", + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, "engines": { - "node": ">=8.0.0" + "node": ">=6" } }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" }, "engines": { - "node": ">=8" + "node": ">=10.13.0" } }, - "node_modules/text-hex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", - "license": "MIT" - }, - "node_modules/tildify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", - "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=4" } }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, - "node_modules/to-buffer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", - "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "license": "MIT", "dependencies": { - "isarray": "^2.0.5", - "safe-buffer": "^5.2.1", - "typed-array-buffer": "^1.0.3" + "prelude-ls": "^1.2.1" }, "engines": { - "node": ">= 0.4" + "node": ">= 0.8.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", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, "engines": { - "node": ">=8.0" + "node": ">=4" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=0.6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/toml": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", - "license": "MIT" + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, - "node_modules/triple-beam": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", - "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "license": "MIT", "engines": { - "node": ">= 14.0.0" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" }, - "node_modules/tweetnacl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", - "license": "Unlicense" + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, "engines": { - "node": ">=4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=0.8.0" } }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "@lukeed/csprng": "^1.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">=8" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, "engines": { - "node": ">= 0.4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } }, "node_modules/unpipe": { "version": "1.0.0", @@ -5893,10 +10691,48 @@ "node": ">= 0.8" } }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, "node_modules/update-browserslist-db": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", - "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -5924,11 +10760,15 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/urijs": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", - "license": "MIT" + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } }, "node_modules/util-deprecate": { "version": "1.0.2", @@ -5945,15 +10785,12 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" }, "node_modules/v8-to-istanbul": { "version": "9.3.0", @@ -5970,6 +10807,15 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -5989,98 +10835,250 @@ "makeerror": "1.0.12" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" + "graceful-fs": "^4.1.2" }, "engines": { - "node": ">= 8" + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" } }, - "node_modules/which-typed-array": { - "version": "1.1.22", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", - "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "node_modules/webpack": { + "version": "5.109.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.2.tgz", + "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.24.4", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" + }, + "bin": { + "webpack": "bin/webpack.js" }, "engines": { - "node": ">= 0.4" + "node": ">=10.13.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-node-externals": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", + "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" } }, - "node_modules/winston": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", - "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "node_modules/webpack/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.8", - "async": "^3.2.3", - "is-stream": "^2.0.0", - "logform": "^2.7.0", - "one-time": "^1.0.0", - "readable-stream": "^3.4.0", - "safe-stable-stringify": "^2.3.1", - "stack-trace": "0.0.x", - "triple-beam": "^1.3.0", - "winston-transport": "^4.9.0" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, - "engines": { - "node": ">= 12.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/winston-daily-rotate-file": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", - "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", + "node_modules/webpack/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "file-stream-rotator": "^0.6.1", - "object-hash": "^3.0.0", - "triple-beam": "^1.4.1", - "winston-transport": "^4.7.0" + "ajv": "^8.0.0" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" }, "peerDependencies": { - "winston": "^3" + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" } }, - "node_modules/winston-transport": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", - "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "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": { - "logform": "^2.7.0", - "readable-stream": "^3.6.2", - "triple-beam": "^1.3.0" + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" }, "engines": { - "node": ">= 12.0.0" + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", @@ -6105,47 +11103,17 @@ "license": "ISC" }, "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" + "signal-exit": "^4.0.1" }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", "engines": { - "node": ">=0.4" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/y18n": { @@ -6165,20 +11133,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yamljs": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", - "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "glob": "^7.0.5" - }, - "bin": { - "json2yaml": "bin/json2yaml", - "yaml2json": "bin/yaml2json" - } - }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", @@ -6208,6 +11162,16 @@ "node": ">=12" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -6221,13 +11185,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { - "url": "https://github.com/sponsors/colinhacks" + "url": "https://github.com/sponsors/sindresorhus" } } } diff --git a/package.json b/package.json index f8c832d..c2fcb13 100644 --- a/package.json +++ b/package.json @@ -1,42 +1,91 @@ { - "name": "smartdrop-backend", - "version": "0.1.0", + "name": "backend", + "version": "0.0.1", + "description": "", + "author": "", "private": true, - "description": "SmartDrop API and indexing services", + "license": "UNLICENSED", "engines": { - "node": ">=20.9.0" + "node": ">=22" }, "scripts": { - "start": "node src/index.js", - "dev": "node --watch src/index.js", + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", - "migrate": "knex --knexfile src/db/knexfile.js migrate:latest", - "migrate:rollback": "knex --knexfile src/db/knexfile.js migrate:rollback" + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { - "axios": "^1.7.0", - "cors": "^2.8.5", - "csv-parser": "^3.2.1", - "dotenv": "^16.4.5", - "envalid": "^8.2.0", - "express": "^4.21.0", - "helmet": "^8.2.0", - "ioredis": "^5.4.1", - "knex": "^3.3.0", - "multer": "^2.2.0", - "node-cron": "^3.0.3", - "pg": "^8.22.0", - "stellar-sdk": "^11.3.0", - "swagger-ui-express": "^5.0.1", - "winston": "^3.14.0", - "winston-daily-rotate-file": "^5.0.0", - "ws": "^8.21.0", - "yamljs": "^0.3.0", - "zod": "^4.4.3" + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.0.1", + "@prisma/client": "6.19.3", + "@stellar/stellar-sdk": "^16.2.0", + "bcrypt": "^6.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "helmet": "^8.3.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" }, "devDependencies": { - "@redocly/cli": "^2.35.1", - "jest": "^29.7.0", - "supertest": "^7.2.2" + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/bcrypt": "^6.0.0", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^24.0.0", + "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^7.0.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^17.11.0", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "prisma": "6.19.3", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "transformIgnorePatterns": [ + "/node_modules/(?!(@stellar/stellar-sdk|@noble|uint8array-extras)/)" + ], + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" } } diff --git a/prisma/migrations/20260803160811_init/migration.sql b/prisma/migrations/20260803160811_init/migration.sql new file mode 100644 index 0000000..b8daaf4 --- /dev/null +++ b/prisma/migrations/20260803160811_init/migration.sql @@ -0,0 +1,170 @@ +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('PLATFORM_ADMIN', 'ORGANIZER', 'ATTENDEE'); + +-- CreateEnum +CREATE TYPE "OrgMemberRole" AS ENUM ('OWNER', 'ADMIN', 'STAFF'); + +-- CreateEnum +CREATE TYPE "Industry" AS ENUM ('CONCERTS', 'FLIGHTS', 'SPORTS', 'FESTIVALS', 'CONFERENCES', 'BUS', 'MOVIE_THEATERS', 'MUSEUMS', 'TOURIST_ATTRACTIONS', 'PUBLIC_TRANSPORT', 'UNIVERSITIES', 'CORPORATE_EVENTS'); + +-- CreateEnum +CREATE TYPE "EventStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "TicketStatus" AS ENUM ('VALID', 'USED', 'REVOKED', 'RESALE'); + +-- CreateEnum +CREATE TYPE "ResaleListingStatus" AS ENUM ('ACTIVE', 'SOLD', 'CANCELLED'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "name" TEXT NOT NULL, + "role" "UserRole" NOT NULL DEFAULT 'ATTENDEE', + "stellarPublicKey" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Organization" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "industry" "Industry" NOT NULL, + "stellarAccount" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OrganizationMember" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "OrgMemberRole" NOT NULL DEFAULT 'STAFF', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OrganizationMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Event" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "category" "Industry" NOT NULL, + "venue" TEXT NOT NULL, + "startsAt" TIMESTAMP(3) NOT NULL, + "endsAt" TIMESTAMP(3), + "chainEventId" BIGINT, + "maxResaleMultiplierBps" INTEGER NOT NULL DEFAULT 11000, + "royaltyBps" INTEGER NOT NULL DEFAULT 500, + "status" "EventStatus" NOT NULL DEFAULT 'DRAFT', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Event_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TicketType" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "price" BIGINT NOT NULL, + "quantityTotal" INTEGER NOT NULL, + "quantityIssued" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TicketType_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Ticket" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "ticketTypeId" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "chainTicketId" BIGINT NOT NULL, + "seat" TEXT NOT NULL DEFAULT 'unassigned', + "status" "TicketStatus" NOT NULL DEFAULT 'VALID', + "qrSecret" TEXT NOT NULL, + "issuedTxHash" TEXT, + "checkedInAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ResaleListing" ( + "id" TEXT NOT NULL, + "ticketId" TEXT NOT NULL, + "sellerId" TEXT NOT NULL, + "price" BIGINT NOT NULL, + "status" "ResaleListingStatus" NOT NULL DEFAULT 'ACTIVE', + "txHash" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ResaleListing_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_stellarPublicKey_key" ON "User"("stellarPublicKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_slug_key" ON "Organization"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "OrganizationMember_organizationId_userId_key" ON "OrganizationMember"("organizationId", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Event_chainEventId_key" ON "Event"("chainEventId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TicketType_eventId_name_key" ON "TicketType"("eventId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Ticket_chainTicketId_key" ON "Ticket"("chainTicketId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Ticket_qrSecret_key" ON "Ticket"("qrSecret"); + +-- AddForeignKey +ALTER TABLE "OrganizationMember" ADD CONSTRAINT "OrganizationMember_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrganizationMember" ADD CONSTRAINT "OrganizationMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Event" ADD CONSTRAINT "Event_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TicketType" ADD CONSTRAINT "TicketType_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_ticketTypeId_fkey" FOREIGN KEY ("ticketTypeId") REFERENCES "TicketType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResaleListing" ADD CONSTRAINT "ResaleListing_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResaleListing" ADD CONSTRAINT "ResaleListing_sellerId_fkey" FOREIGN KEY ("sellerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..7dc11f1 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,174 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum UserRole { + PLATFORM_ADMIN + ORGANIZER + ATTENDEE +} + +enum OrgMemberRole { + OWNER + ADMIN + STAFF +} + +/// Mirrors the 12 supported verticals. Kept as an enum on the backend (unlike +/// the contract's free-text `category`) so the dashboard/marketplace can +/// filter and render industry-specific copy without string matching. +enum Industry { + CONCERTS + FLIGHTS + SPORTS + FESTIVALS + CONFERENCES + BUS + MOVIE_THEATERS + MUSEUMS + TOURIST_ATTRACTIONS + PUBLIC_TRANSPORT + UNIVERSITIES + CORPORATE_EVENTS +} + +enum EventStatus { + DRAFT + PUBLISHED + CANCELLED +} + +/// Cached projection of the on-chain `TicketStatus`. The contract remains the +/// source of truth; this column exists so listing/search queries don't need +/// a Soroban RPC round trip, and is reconciled on every write path and by a +/// periodic reconciliation job. +enum TicketStatus { + VALID + USED + REVOKED + RESALE +} + +enum ResaleListingStatus { + ACTIVE + SOLD + CANCELLED +} + +model User { + id String @id @default(uuid()) + email String @unique + passwordHash String + name String + role UserRole @default(ATTENDEE) + /// Stellar (G...) address used to receive tickets and settle payments. + stellarPublicKey String? @unique + memberships OrganizationMember[] + tickets Ticket[] + resaleListings ResaleListing[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Organization { + id String @id @default(uuid()) + name String + slug String @unique + industry Industry + /// Stellar (G...) address that signs `create_event`/`issue_ticket` calls + /// and receives primary-sale + royalty payments for this organizer. + stellarAccount String + members OrganizationMember[] + events Event[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model OrganizationMember { + id String @id @default(uuid()) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + role OrgMemberRole @default(STAFF) + createdAt DateTime @default(now()) + + @@unique([organizationId, userId]) +} + +model Event { + id String @id @default(uuid()) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String + name String + category Industry + venue String + startsAt DateTime + endsAt DateTime? + /// The `event_id` (u64) this event was registered under on the ticketing + /// contract via `create_event`. Null until the on-chain call succeeds. + chainEventId BigInt? @unique + maxResaleMultiplierBps Int @default(11000) + royaltyBps Int @default(500) + status EventStatus @default(DRAFT) + ticketTypes TicketType[] + tickets Ticket[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model TicketType { + id String @id @default(uuid()) + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + eventId String + name String + /// Face-value price in the platform's settlement token's smallest unit. + price BigInt + quantityTotal Int + quantityIssued Int @default(0) + tickets Ticket[] + createdAt DateTime @default(now()) + + @@unique([eventId, name]) +} + +model Ticket { + id String @id @default(uuid()) + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + eventId String + ticketType TicketType @relation(fields: [ticketTypeId], references: [id]) + ticketTypeId String + owner User @relation(fields: [ownerId], references: [id]) + ownerId String + /// The `ticket_id` (u64) returned by `issue_ticket`/`purchase_primary`. + chainTicketId BigInt @unique + seat String @default("unassigned") + status TicketStatus @default(VALID) + /// Opaque per-ticket secret embedded in the scannable QR code; verification + /// checks this against the DB *and* the on-chain owner/status before a gate + /// scan is accepted, so a photographed QR code alone can't be reused. + qrSecret String @unique @default(uuid()) + issuedTxHash String? + checkedInAt DateTime? + resaleListings ResaleListing[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model ResaleListing { + id String @id @default(uuid()) + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + ticketId String + seller User @relation(fields: [sellerId], references: [id]) + sellerId String + price BigInt + status ResaleListingStatus @default(ACTIVE) + txHash String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts new file mode 100644 index 0000000..c53905f --- /dev/null +++ b/src/app.controller.spec.ts @@ -0,0 +1,25 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +describe('AppController', () => { + let appController: AppController; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); + + appController = app.get(AppController); + }); + + describe('health', () => { + it('reports ok status', () => { + expect(appController.getHealth()).toEqual({ + status: 'ok', + service: 'stellar-tickets-backend', + }); + }); + }); +}); diff --git a/src/app.controller.ts b/src/app.controller.ts new file mode 100644 index 0000000..4fd6521 --- /dev/null +++ b/src/app.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get('health') + getHealth() { + return this.appService.getHealth(); + } +} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..5ba97b0 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { validate } from './config/env.validation'; +import { PrismaModule } from './prisma/prisma.module'; +import { AuthModule } from './auth/auth.module'; +import { UsersModule } from './users/users.module'; +import { OrganizationsModule } from './organizations/organizations.module'; +import { EventsModule } from './events/events.module'; +import { TicketsModule } from './tickets/tickets.module'; +import { StellarModule } from './stellar/stellar.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true, validate }), + PrismaModule, + StellarModule, + AuthModule, + UsersModule, + OrganizationsModule, + EventsModule, + TicketsModule, + ], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} diff --git a/src/app.service.ts b/src/app.service.ts new file mode 100644 index 0000000..07f3229 --- /dev/null +++ b/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHealth() { + return { status: 'ok', service: 'stellar-tickets-backend' }; + } +} diff --git a/src/auth/auth.controller.spec.ts b/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000..7bcb0d8 --- /dev/null +++ b/src/auth/auth.controller.spec.ts @@ -0,0 +1,36 @@ +import { AuthController } from './auth.controller'; +import type { AuthService } from './auth.service'; + +describe('AuthController', () => { + let controller: AuthController; + let authService: { register: jest.Mock; login: jest.Mock }; + + beforeEach(() => { + authService = { register: jest.fn(), login: jest.fn() }; + controller = new AuthController(authService as unknown as AuthService); + }); + + it('delegates register to AuthService.register', async () => { + authService.register.mockResolvedValue({ accessToken: 'token' }); + const dto = { + email: 'ada@example.com', + password: 'correct-horse-battery', + name: 'Ada', + }; + + const result = await controller.register(dto); + + expect(authService.register).toHaveBeenCalledWith(dto); + expect(result).toEqual({ accessToken: 'token' }); + }); + + it('delegates login to AuthService.login', async () => { + authService.login.mockResolvedValue({ accessToken: 'token' }); + const dto = { email: 'ada@example.com', password: 'correct-horse-battery' }; + + const result = await controller.login(dto); + + expect(authService.login).toHaveBeenCalledWith(dto); + expect(result).toEqual({ accessToken: 'token' }); + }); +}); diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..19fc57e --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,21 @@ +import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { RegisterDto } from './dto/register.dto'; +import { LoginDto } from './dto/login.dto'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('register') + @HttpCode(HttpStatus.CREATED) + register(@Body() dto: RegisterDto) { + return this.authService.register(dto); + } + + @Post('login') + @HttpCode(HttpStatus.OK) + login(@Body() dto: LoginDto) { + return this.authService.login(dto); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..eea9dfa --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './strategies/jwt.strategy'; + +@Module({ + imports: [ + PassportModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.getOrThrow('JWT_SECRET'), + signOptions: { expiresIn: '1h' }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [JwtModule], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..5ead14b --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,104 @@ +import { ConflictException, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; +import { AuthService } from './auth.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('AuthService', () => { + let service: AuthService; + let prisma: { user: { findUnique: jest.Mock; create: jest.Mock } }; + let jwt: { sign: jest.Mock }; + + beforeEach(() => { + prisma = { user: { findUnique: jest.fn(), create: jest.fn() } }; + jwt = { sign: jest.fn().mockReturnValue('signed.jwt.token') }; + service = new AuthService( + prisma as unknown as PrismaService, + jwt as unknown as JwtService, + ); + }); + + describe('register', () => { + it('hashes the password and issues a token for a new email', async () => { + prisma.user.findUnique.mockResolvedValue(null); + prisma.user.create.mockImplementation(({ data }) => + Promise.resolve({ id: 'user-1', role: 'ATTENDEE', ...data }), + ); + + const result = await service.register({ + email: 'attendee@example.com', + password: 'correct-horse-battery', + name: 'Ada Lovelace', + }); + + expect(prisma.user.create).toHaveBeenCalledTimes(1); + const createCall = prisma.user.create.mock.calls[0] as [ + { data: { passwordHash: string } }, + ]; + const storedHash = createCall[0].data.passwordHash; + expect(storedHash).not.toBe('correct-horse-battery'); + expect(await bcrypt.compare('correct-horse-battery', storedHash)).toBe( + true, + ); + expect(result.accessToken).toBe('signed.jwt.token'); + expect(result.user.email).toBe('attendee@example.com'); + }); + + it('rejects a duplicate email', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'existing' }); + + await expect( + service.register({ + email: 'dup@example.com', + password: 'x'.repeat(12), + name: 'Dup', + }), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + }); + + describe('login', () => { + it('issues a token when the password matches', async () => { + const passwordHash = await bcrypt.hash('correct-horse-battery', 4); + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: 'attendee@example.com', + name: 'Ada Lovelace', + role: 'ATTENDEE', + passwordHash, + }); + + const result = await service.login({ + email: 'attendee@example.com', + password: 'correct-horse-battery', + }); + + expect(result.accessToken).toBe('signed.jwt.token'); + }); + + it('rejects a wrong password without leaking which field was wrong', async () => { + const passwordHash = await bcrypt.hash('correct-horse-battery', 4); + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: 'attendee@example.com', + passwordHash, + }); + + await expect( + service.login({ + email: 'attendee@example.com', + password: 'wrong-password', + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rejects an unknown email', async () => { + prisma.user.findUnique.mockResolvedValue(null); + + await expect( + service.login({ email: 'nobody@example.com', password: 'whatever12' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..1d60893 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,62 @@ +import { + ConflictException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; +import { PrismaService } from '../prisma/prisma.service'; +import { RegisterDto } from './dto/register.dto'; +import { LoginDto } from './dto/login.dto'; + +const BCRYPT_ROUNDS = 12; + +export interface AuthResult { + accessToken: string; + user: { id: string; email: string; name: string; role: string }; +} + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + ) {} + + async register(dto: RegisterDto): Promise { + const existing = await this.prisma.user.findUnique({ + where: { email: dto.email }, + }); + if (existing) { + throw new ConflictException('An account with this email already exists'); + } + + const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS); + const user = await this.prisma.user.create({ + data: { email: dto.email, passwordHash, name: dto.name }, + }); + + return this.buildAuthResult(user.id, user.email, user.name, user.role); + } + + async login(dto: LoginDto): Promise { + const user = await this.prisma.user.findUnique({ + where: { email: dto.email }, + }); + if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) { + throw new UnauthorizedException('Invalid email or password'); + } + + return this.buildAuthResult(user.id, user.email, user.name, user.role); + } + + private buildAuthResult( + id: string, + email: string, + name: string, + role: string, + ): AuthResult { + const accessToken = this.jwt.sign({ sub: id, email, role }); + return { accessToken, user: { id, email, name, role } }; + } +} diff --git a/src/auth/decorators/current-user.decorator.ts b/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..692380d --- /dev/null +++ b/src/auth/decorators/current-user.decorator.ts @@ -0,0 +1,17 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import type { Request } from 'express'; + +export interface CurrentUserPayload { + userId: string; + email: string; + role: string; +} + +export const CurrentUser = createParamDecorator( + (_: unknown, ctx: ExecutionContext): CurrentUserPayload => { + const request = ctx + .switchToHttp() + .getRequest(); + return request.user; + }, +); diff --git a/src/auth/decorators/roles.decorator.ts b/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..e038e16 --- /dev/null +++ b/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/auth/dto/login.dto.spec.ts b/src/auth/dto/login.dto.spec.ts new file mode 100644 index 0000000..3203c49 --- /dev/null +++ b/src/auth/dto/login.dto.spec.ts @@ -0,0 +1,31 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { LoginDto } from './login.dto'; + +describe('LoginDto', () => { + it('accepts a well-formed payload', async () => { + const dto = plainToInstance(LoginDto, { + email: 'ada@example.com', + password: 'anything', + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('rejects a malformed email', async () => { + const dto = plainToInstance(LoginDto, { + email: 'nope', + password: 'anything', + }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'email')).toBe(true); + }); + + it('rejects a non-string password', async () => { + const dto = plainToInstance(LoginDto, { + email: 'ada@example.com', + password: 12345, + }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'password')).toBe(true); + }); +}); diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..1d3893d --- /dev/null +++ b/src/auth/dto/login.dto.ts @@ -0,0 +1,9 @@ +import { IsEmail, IsString } from 'class-validator'; + +export class LoginDto { + @IsEmail() + email: string; + + @IsString() + password: string; +} diff --git a/src/auth/dto/register.dto.spec.ts b/src/auth/dto/register.dto.spec.ts new file mode 100644 index 0000000..68b807f --- /dev/null +++ b/src/auth/dto/register.dto.spec.ts @@ -0,0 +1,34 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { RegisterDto } from './register.dto'; + +function build(overrides: Record = {}) { + return plainToInstance(RegisterDto, { + email: 'ada@example.com', + password: 'correct-horse-battery', + name: 'Ada Lovelace', + ...overrides, + }); +} + +describe('RegisterDto', () => { + it('accepts a well-formed payload', async () => { + const errors = await validate(build()); + expect(errors).toHaveLength(0); + }); + + it('rejects a malformed email', async () => { + const errors = await validate(build({ email: 'not-an-email' })); + expect(errors.some((e) => e.property === 'email')).toBe(true); + }); + + it('rejects a password shorter than 10 characters', async () => { + const errors = await validate(build({ password: 'short1' })); + expect(errors.some((e) => e.property === 'password')).toBe(true); + }); + + it('rejects an empty name', async () => { + const errors = await validate(build({ name: '' })); + expect(errors.some((e) => e.property === 'name')).toBe(true); + }); +}); diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts new file mode 100644 index 0000000..b37d7df --- /dev/null +++ b/src/auth/dto/register.dto.ts @@ -0,0 +1,14 @@ +import { IsEmail, IsString, MinLength } from 'class-validator'; + +export class RegisterDto { + @IsEmail() + email: string; + + @IsString() + @MinLength(10) + password: string; + + @IsString() + @MinLength(1) + name: string; +} diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/src/auth/guards/roles.guard.spec.ts b/src/auth/guards/roles.guard.spec.ts new file mode 100644 index 0000000..ef4a06e --- /dev/null +++ b/src/auth/guards/roles.guard.spec.ts @@ -0,0 +1,55 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RolesGuard } from './roles.guard'; + +function mockContext(user: { role: string } | undefined): ExecutionContext { + return { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ + getRequest: () => ({ user }), + }), + } as unknown as ExecutionContext; +} + +describe('RolesGuard', () => { + it('allows the request through when no roles are required', () => { + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue(undefined), + }; + const guard = new RolesGuard(reflector as unknown as Reflector); + + expect(guard.canActivate(mockContext(undefined))).toBe(true); + }); + + it('rejects an unauthenticated request when roles are required', () => { + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue(['ORGANIZER']), + }; + const guard = new RolesGuard(reflector as unknown as Reflector); + + expect(() => guard.canActivate(mockContext(undefined))).toThrow( + ForbiddenException, + ); + }); + + it('rejects a user whose role is not in the required list', () => { + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue(['ORGANIZER']), + }; + const guard = new RolesGuard(reflector as unknown as Reflector); + + expect(() => guard.canActivate(mockContext({ role: 'ATTENDEE' }))).toThrow( + ForbiddenException, + ); + }); + + it('allows a user whose role matches', () => { + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue(['ORGANIZER']), + }; + const guard = new RolesGuard(reflector as unknown as Reflector); + + expect(guard.canActivate(mockContext({ role: 'ORGANIZER' }))).toBe(true); + }); +}); diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..9cb550d --- /dev/null +++ b/src/auth/guards/roles.guard.ts @@ -0,0 +1,33 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { Request } from 'express'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import type { CurrentUserPayload } from '../decorators/current-user.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const { user } = context + .switchToHttp() + .getRequest(); + if (!user || !requiredRoles.includes(user.role)) { + throw new ForbiddenException('Insufficient permissions for this action'); + } + return true; + } +} diff --git a/src/auth/strategies/jwt.strategy.spec.ts b/src/auth/strategies/jwt.strategy.spec.ts new file mode 100644 index 0000000..e20ed00 --- /dev/null +++ b/src/auth/strategies/jwt.strategy.spec.ts @@ -0,0 +1,29 @@ +import { ConfigService } from '@nestjs/config'; +import { JwtStrategy } from './jwt.strategy'; + +describe('JwtStrategy', () => { + it('maps a decoded payload to the shape guards and controllers expect', () => { + const config = { getOrThrow: jest.fn().mockReturnValue('x'.repeat(32)) }; + const strategy = new JwtStrategy(config as unknown as ConfigService); + + const result = strategy.validate({ + sub: 'user-1', + email: 'ada@example.com', + role: 'ORGANIZER', + }); + + expect(result).toEqual({ + userId: 'user-1', + email: 'ada@example.com', + role: 'ORGANIZER', + }); + }); + + it('reads JWT_SECRET from config at construction time', () => { + const config = { getOrThrow: jest.fn().mockReturnValue('x'.repeat(32)) }; + const strategy = new JwtStrategy(config as unknown as ConfigService); + expect(strategy).toBeDefined(); + + expect(config.getOrThrow).toHaveBeenCalledWith('JWT_SECRET'); + }); +}); diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..bbce4b1 --- /dev/null +++ b/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; + +export interface JwtPayload { + sub: string; + email: string; + role: string; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(config: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: config.getOrThrow('JWT_SECRET'), + }); + } + + validate(payload: JwtPayload) { + return { userId: payload.sub, email: payload.email, role: payload.role }; + } +} diff --git a/src/common/decorators/is-stellar-public-key.decorator.spec.ts b/src/common/decorators/is-stellar-public-key.decorator.spec.ts new file mode 100644 index 0000000..383eb31 --- /dev/null +++ b/src/common/decorators/is-stellar-public-key.decorator.spec.ts @@ -0,0 +1,36 @@ +import { validate } from 'class-validator'; +import { IsStellarPublicKey } from './is-stellar-public-key.decorator'; + +class TestDto { + @IsStellarPublicKey() + stellarAccount: string; +} + +describe('IsStellarPublicKey', () => { + it('accepts a valid ed25519 Stellar public key', async () => { + const dto = new TestDto(); + dto.stellarAccount = + 'GBAHZWO3UI3GAHPQCPSW6IR5N7HJ4UBRZNAFMSYB6DAKVNHQDOZIV2YJ'; + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects a string that is not a valid public key', async () => { + const dto = new TestDto(); + dto.stellarAccount = 'not-a-real-address'; + + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toHaveProperty('isStellarPublicKey'); + }); + + it('rejects a secret key (S...) passed where a public key belongs', async () => { + const dto = new TestDto(); + dto.stellarAccount = + 'SA4IGCSRIDQ3L4274BRKUGPMVCDB4A6IHFKRGMHLLZ7TPOIC4TEEI6QX'; + + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); +}); diff --git a/src/common/decorators/is-stellar-public-key.decorator.ts b/src/common/decorators/is-stellar-public-key.decorator.ts new file mode 100644 index 0000000..f313a23 --- /dev/null +++ b/src/common/decorators/is-stellar-public-key.decorator.ts @@ -0,0 +1,24 @@ +import { registerDecorator, ValidationOptions } from 'class-validator'; +import { StrKey } from '@stellar/stellar-sdk'; + +/** Validates a Stellar G... ed25519 public key using strkey's own checksum, not a regex. */ +export function IsStellarPublicKey(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isStellarPublicKey', + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown): boolean { + return ( + typeof value === 'string' && StrKey.isValidEd25519PublicKey(value) + ); + }, + defaultMessage(): string { + return '$property must be a valid Stellar public key (G...)'; + }, + }, + }); + }; +} diff --git a/src/config.js b/src/config.js deleted file mode 100644 index a871bbe..0000000 --- a/src/config.js +++ /dev/null @@ -1,226 +0,0 @@ -require('dotenv').config(); - -const { cleanEnv, makeValidator, num, port, str, url } = require('envalid'); - -const stellarAddress = makeValidator((input) => { - if (!/^G[A-Z0-9]{55}$/.test(input)) { - throw new Error('must be a valid Stellar public key'); - } - return input; -}); - -function parseWatchedAssets(input) { - if (!input || !input.trim()) return []; - - const seen = new Set(); - return input - .split(',') - .map((entry) => entry.trim()) - .filter(Boolean) - .map((entry) => { - const [code, issuer, extra] = entry.split(':'); - - if (extra !== undefined) { - throw new Error(`invalid asset "${entry}"; expected CODE or CODE:ISSUER`); - } - - if (!/^[A-Z0-9]{1,12}$/.test(code)) { - throw new Error(`invalid asset code "${code}"; expected 1-12 uppercase alphanumeric characters`); - } - - if (issuer !== undefined && !/^G[A-Z0-9]{55}$/.test(issuer)) { - throw new Error(`invalid issuer for "${code}"; expected a Stellar public key`); - } - - const asset = { code, issuer: issuer || null }; - const key = asset.issuer ? `${asset.code}:${asset.issuer}` : asset.code; - if (seen.has(key)) return null; - seen.add(key); - return asset; - }) - .filter(Boolean); -} - -const watchedAssets = makeValidator(parseWatchedAssets); - -const positiveInteger = makeValidator((input) => { - const value = Number(input); - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error('must be a positive integer'); - } - return value; -}); - -const databaseDevDefault = - process.env.NODE_ENV === 'test' - ? 'postgres://localhost/smartdrop_test' - : 'postgres://localhost/smartdrop'; - -const rawEnv = { - ...process.env, - NODE_ENV: process.env.NODE_ENV || 'development', -}; - -const env = cleanEnv(rawEnv, { - NODE_ENV: str({ - default: 'development', - choices: ['development', 'test', 'production'], - }), - PORT: port({ default: 3000 }), - REDIS_URL: url({ devDefault: 'redis://localhost:6379' }), - DATABASE_URL: url({ devDefault: databaseDevDefault }), - STELLAR_HORIZON_URL: url({ default: 'https://horizon.stellar.org' }), - USDC_ISSUER: stellarAddress({ - default: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - }), - COINGECKO_API_KEY: str({ default: '' }), - COINMARKETCAP_API_KEY: str({ default: '' }), - INSTANCE_ID: str({ default: '' }), - LEASE_TTL_MS: positiveInteger({ default: 15000 }), - LEASE_RENEW_INTERVAL_MS: positiveInteger({ default: 5000 }), - ADMIN_API_KEY: str({ default: '' }), - AIRDROP_CSV_MAX_BYTES: positiveInteger({ default: 5 * 1024 * 1024 }), - AIRDROP_JSON_MAX_BYTES: positiveInteger({ default: 2 * 1024 * 1024 }), - AIRDROP_RATELIMIT_WINDOW: positiveInteger({ default: 60 }), - AIRDROP_RATELIMIT_MAX: positiveInteger({ default: 10 }), - PRICE_CACHE_TTL_SECONDS: num({ default: 60 }), - PRICE_REFRESH_INTERVAL_SECONDS: num({ default: 30 }), - PRICE_STALE_THRESHOLD_MINUTES: num({ default: 5 }), - PRICE_ANOMALY_THRESHOLD_PCT: num({ default: 20 }), - CIRCUIT_BREAKER_FAILURE_THRESHOLD: num({ default: 3 }), - CIRCUIT_BREAKER_SUCCESS_THRESHOLD: num({ default: 1 }), - CIRCUIT_BREAKER_TIMEOUT_MS: num({ default: 30000 }), - PRICE_SOURCE_CIRCUIT_COOLDOWN_MS: num({ default: 15 * 60 * 1000 }), - PRICE_SOURCE_CIRCUIT_REMINDER_MS: num({ default: 5 * 60 * 1000 }), - AIRDROP_EXPIRY_CHECK_INTERVAL_SECONDS: num({ default: 60 }), - AIRDROP_LEDGER_CACHE_TTL_MS: num({ default: 5000 }), - AIRDROP_EXPIRY_SCAN_BATCH_SIZE: num({ default: 100 }), - WATCHED_ASSETS: watchedAssets({ default: '' }), - LOG_LEVEL: str({ - default: 'info', - choices: ['debug', 'info', 'warn', 'error'], - }), -}); - -const usdcIssuer = env.USDC_ISSUER; -const parsedWatchedAssets = Array.isArray(env.WATCHED_ASSETS) - ? env.WATCHED_ASSETS - : parseWatchedAssets(env.WATCHED_ASSETS); - -module.exports = { - nodeEnv: env.NODE_ENV, - port: env.PORT, - databaseUrl: env.DATABASE_URL, - redis: { - url: env.REDIS_URL, - }, - stellar: { - horizonUrl: env.STELLAR_HORIZON_URL, - sorobanRpcUrl: process.env.SOROBAN_RPC_URL || 'https://soroban-rpc.mainnet.stellar.gateway.fm', - usdcIssuer, - }, - indexer: { - enabled: process.env.INDEXER_ENABLED !== 'false', - contractId: process.env.SMARTDROP_CONTRACT_ID || '', - pollIntervalMs: parseInt(process.env.INDEXER_POLL_INTERVAL_MS, 10) || 5000, - pollLimit: parseInt(process.env.INDEXER_POLL_LIMIT, 10) || 100, - startLedger: parseInt(process.env.INDEXER_START_LEDGER, 10) || 0, - }, - coingecko: { - apiKey: env.COINGECKO_API_KEY, - baseUrl: 'https://api.coingecko.com/api/v3', - }, - coinmarketcap: { - apiKey: env.COINMARKETCAP_API_KEY, - baseUrl: 'https://pro-api.coinmarketcap.com/v1', - assetIssuerMap: { - XLM: { symbol: 'XLM' }, - [`USDC:${usdcIssuer}`]: { id: 3408 }, - }, - }, - price: { - cacheTtl: env.PRICE_CACHE_TTL_SECONDS, - refreshInterval: env.PRICE_REFRESH_INTERVAL_SECONDS, - staleThresholdMinutes: env.PRICE_STALE_THRESHOLD_MINUTES, - anomalyThresholdPercent: env.PRICE_ANOMALY_THRESHOLD_PCT, - circuitBreaker: { - failureThreshold: env.CIRCUIT_BREAKER_FAILURE_THRESHOLD, - successThreshold: env.CIRCUIT_BREAKER_SUCCESS_THRESHOLD, - timeoutMs: env.CIRCUIT_BREAKER_TIMEOUT_MS, - }, - }, - priceSources: { - // How long a source's circuit stays open after a nonRetryable (e.g. 401) - // failure before it's attempted again. - circuitCooldownMs: env.PRICE_SOURCE_CIRCUIT_COOLDOWN_MS, - // Minimum gap between repeated "circuit open, skipping" log lines while - // the circuit stays open, so a misconfigured key doesn't spam one log - // line per fetch cycle for the entire cooldown window. - circuitReminderIntervalMs: env.PRICE_SOURCE_CIRCUIT_REMINDER_MS, - }, - airdrops: { - // How often the expiry reconciliation job scans non-terminal airdrops - // against the live Horizon ledger sequence. - expiryCheckIntervalSeconds: env.AIRDROP_EXPIRY_CHECK_INTERVAL_SECONDS, - // getCurrentLedger() is a live Horizon call with no caching; a job that - // polls frequently should reuse the same ledger sequence for this long - // rather than hitting Horizon once per airdrop per cycle. - ledgerCacheTtlMs: env.AIRDROP_LEDGER_CACHE_TTL_MS, - // SSCAN batch size used when scanning the full airdrop ID set — keeps - // each Redis round-trip small instead of loading the whole set (SMEMBERS) - // into memory at once. - expiryScanBatchSize: env.AIRDROP_EXPIRY_SCAN_BATCH_SIZE, - csvMaxBytes: env.AIRDROP_CSV_MAX_BYTES, - jsonMaxBytes: env.AIRDROP_JSON_MAX_BYTES, - maxRecipients: 10000, - rateLimit: { - windowSeconds: env.AIRDROP_RATELIMIT_WINDOW, - max: env.AIRDROP_RATELIMIT_MAX, - }, - }, - watchedAssets: parsedWatchedAssets, - leaderElection: { - instanceId: env.INSTANCE_ID || `${require('os').hostname()}-${require('crypto').randomUUID().slice(0, 8)}`, - leaseTtlMs: env.LEASE_TTL_MS, - renewIntervalMs: env.LEASE_RENEW_INTERVAL_MS, - }, - auth: { - adminApiKey: env.ADMIN_API_KEY, - }, - corsAllowedOrigins: (process.env.CORS_ALLOWED_ORIGINS || 'http://localhost:3000,http://localhost:3001') - .split(',') - .map((o) => o.trim()) - .filter(Boolean), - rateLimit: { - windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000, - max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100, - }, - priceRateLimit: { - windowSeconds: parseInt(process.env.PRICE_RATELIMIT_WINDOW, 10) || 60, - max: parseInt(process.env.PRICE_RATELIMIT_MAX, 10) || 30, - }, - webhooks: { - maxAttempts: parseInt(process.env.WEBHOOK_MAX_ATTEMPTS, 10) || 3, - retryBaseMs: parseInt(process.env.WEBHOOK_RETRY_BASE_MS, 10) || 30000, - retryFactor: parseFloat(process.env.WEBHOOK_RETRY_FACTOR) || 2, - timeoutMs: parseInt(process.env.WEBHOOK_TIMEOUT_MS, 10) || 5000, - // retryPollMs/retryBatchSize: #128 considered retuning these once - // backoffMs() gained jitter (a wider spread of nextRetryAt values could - // argue for a shorter poll interval and/or smaller batch, since due - // items are less likely to arrive in one dense cluster). Left - // unchanged here — jitter already substantially reduces the size of - // any one burst on its own, and retuning the poll/batch knobs is a - // separate operational tradeoff (worker load vs. retry latency) worth - // its own measurement rather than a guess made alongside this fix. - retryPollMs: parseInt(process.env.WEBHOOK_RETRY_POLL_MS, 10) || 5000, - retryBatchSize: parseInt(process.env.WEBHOOK_RETRY_BATCH, 10) || 25, - rateLimit: { - windowSeconds: parseInt(process.env.WEBHOOK_RATELIMIT_WINDOW, 10) || 60, - max: parseInt(process.env.WEBHOOK_RATELIMIT_MAX, 10) || 60, - }, - testRateLimit: { - windowSeconds: parseInt(process.env.WEBHOOK_TEST_RATELIMIT_WINDOW, 10) || 60, - max: parseInt(process.env.WEBHOOK_TEST_RATELIMIT_MAX, 10) || 5, - }, - }, -}; diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts new file mode 100644 index 0000000..dd71b07 --- /dev/null +++ b/src/config/env.validation.spec.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata'; +import { validate } from './env.validation'; + +function validConfig(overrides: Record = {}) { + return { + NODE_ENV: 'test', + PORT: 3000, + DATABASE_URL: 'postgresql://user:pass@localhost:5432/db', + JWT_SECRET: 'x'.repeat(32), + APP_URL: 'http://localhost:3001', + SOROBAN_RPC_URL: 'https://soroban-testnet.stellar.org', + STELLAR_NETWORK: 'testnet', + TICKETING_CONTRACT_ID: 'C'.repeat(56), + PLATFORM_SIGNER_SECRET: 'S'.repeat(56), + ...overrides, + }; +} + +describe('env.validate', () => { + it('accepts a fully populated, well-formed config', () => { + expect(() => validate(validConfig())).not.toThrow(); + }); + + it('rejects an unrecognized NODE_ENV', () => { + expect(() => validate(validConfig({ NODE_ENV: 'staging' }))).toThrow(); + }); + + it('rejects a JWT_SECRET shorter than 32 characters', () => { + expect(() => validate(validConfig({ JWT_SECRET: 'too-short' }))).toThrow(); + }); + + it('rejects an unrecognized STELLAR_NETWORK', () => { + expect(() => + validate(validConfig({ STELLAR_NETWORK: 'devnet' })), + ).toThrow(); + }); + + it('rejects a missing required field', () => { + const config = validConfig(); + delete (config as Record).DATABASE_URL; + expect(() => validate(config)).toThrow(); + }); +}); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts new file mode 100644 index 0000000..4e0756f --- /dev/null +++ b/src/config/env.validation.ts @@ -0,0 +1,55 @@ +import { plainToInstance } from 'class-transformer'; +import { + IsIn, + IsInt, + IsString, + MinLength, + validateSync, +} from 'class-validator'; + +class EnvironmentVariables { + @IsIn(['development', 'test', 'production']) + NODE_ENV: string; + + @IsInt() + PORT: number; + + @IsString() + DATABASE_URL: string; + + @IsString() + @MinLength(32) + JWT_SECRET: string; + + @IsString() + APP_URL: string; + + /// Soroban RPC endpoint the StellarService submits contract calls through. + @IsString() + SOROBAN_RPC_URL: string; + + @IsIn(['testnet', 'futurenet', 'mainnet']) + STELLAR_NETWORK: string; + + /// Deployed `ticketing` contract's C... address. + @IsString() + TICKETING_CONTRACT_ID: string; + + /// Platform signer used for contract calls submitted on behalf of the + /// backend itself (e.g. relaying an organizer's already-authorized op). + @IsString() + PLATFORM_SIGNER_SECRET: string; +} + +export function validate(config: Record) { + const validated = plainToInstance(EnvironmentVariables, config, { + enableImplicitConversion: true, + }); + const errors = validateSync(validated, { skipMissingProperties: false }); + + if (errors.length > 0) { + throw new Error(`Invalid environment configuration: ${errors.toString()}`); + } + + return validated; +} diff --git a/src/db/index.js b/src/db/index.js deleted file mode 100644 index ebcaa25..0000000 --- a/src/db/index.js +++ /dev/null @@ -1,23 +0,0 @@ -const knex = require('knex'); -const knexfile = require('./knexfile'); - -/** - * @type {import('knex').Knex} - */ -const db = knex(knexfile); - -module.exports = { - db, - - /** Query helper for airdrops table */ - airdrops: () => db('airdrops'), - - /** Query helper for recipients table */ - recipients: () => db('recipients'), - - /** Query helper for contract_events table */ - contractEvents: () => db('contract_events'), - - /** Query helper for indexer_state table */ - indexerState: () => db('indexer_state') -}; diff --git a/src/db/knexfile.js b/src/db/knexfile.js deleted file mode 100644 index 0480a30..0000000 --- a/src/db/knexfile.js +++ /dev/null @@ -1,14 +0,0 @@ -const config = require('../config'); - -module.exports = { - client: 'pg', - connection: config.databaseUrl, - pool: { - min: 2, - max: 10 - }, - migrations: { - directory: './migrations', - tableName: 'knex_migrations' - } -}; diff --git a/src/db/migrations/20260626000000_initial_schema.js b/src/db/migrations/20260626000000_initial_schema.js deleted file mode 100644 index 0d27a0b..0000000 --- a/src/db/migrations/20260626000000_initial_schema.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.up = async function(knex) { - await knex.raw(` - -- Airdrop campaigns - CREATE TABLE airdrops ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - contract_id TEXT NOT NULL, - creator TEXT NOT NULL, - token TEXT NOT NULL, - total_amount BIGINT NOT NULL, - expiry_ledger BIGINT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() - ); - - -- Individual recipients - CREATE TABLE recipients ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - airdrop_id UUID REFERENCES airdrops(id), - address TEXT NOT NULL, - amount BIGINT NOT NULL, - claimed_at TIMESTAMPTZ, - ledger BIGINT - ); - - -- Raw contract events - CREATE TABLE contract_events ( - id BIGSERIAL PRIMARY KEY, - ledger BIGINT NOT NULL, - tx_hash TEXT NOT NULL, - event_type TEXT NOT NULL, - payload JSONB NOT NULL, - indexed_at TIMESTAMPTZ DEFAULT NOW() - ); - - -- Indexer cursor - CREATE TABLE indexer_state ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ); - `); -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = async function(knex) { - await knex.raw(` - DROP TABLE IF EXISTS indexer_state; - DROP TABLE IF EXISTS contract_events; - DROP TABLE IF EXISTS recipients; - DROP TABLE IF EXISTS airdrops; - `); -}; diff --git a/src/errors/AppError.js b/src/errors/AppError.js deleted file mode 100644 index 35c636b..0000000 --- a/src/errors/AppError.js +++ /dev/null @@ -1,29 +0,0 @@ -'use strict'; - -const ERROR_CODES = Object.freeze({ - VALIDATION_ERROR: { statusCode: 400 }, - UNAUTHORIZED: { statusCode: 401 }, - NOT_FOUND: { statusCode: 404 }, - PAYLOAD_TOO_LARGE: { statusCode: 413 }, - RATE_LIMITED: { statusCode: 429 }, - UPSTREAM_ERROR: { statusCode: 502 }, - INTERNAL_ERROR: { statusCode: 500 }, -}); - -class AppError extends Error { - constructor(code, message, statusCode, details = {}) { - super(message); - if (!ERROR_CODES[code]) { - throw new Error(`Unknown application error code: ${code}`); - } - this.name = 'AppError'; - this.code = code; - this.statusCode = statusCode || ERROR_CODES[code].statusCode; - this.details = details; - Error.captureStackTrace?.(this, AppError); - } -} - -AppError.codes = ERROR_CODES; - -module.exports = AppError; diff --git a/src/events/dto/confirm-publish.dto.spec.ts b/src/events/dto/confirm-publish.dto.spec.ts new file mode 100644 index 0000000..3b40508 --- /dev/null +++ b/src/events/dto/confirm-publish.dto.spec.ts @@ -0,0 +1,18 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { ConfirmPublishDto } from './confirm-publish.dto'; + +describe('ConfirmPublishDto', () => { + it('accepts a string signedXdr', async () => { + const dto = plainToInstance(ConfirmPublishDto, { + signedXdr: 'AAAAAgAAAAA=', + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('rejects a missing signedXdr', async () => { + const dto = plainToInstance(ConfirmPublishDto, {}); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'signedXdr')).toBe(true); + }); +}); diff --git a/src/events/dto/confirm-publish.dto.ts b/src/events/dto/confirm-publish.dto.ts new file mode 100644 index 0000000..ccd6861 --- /dev/null +++ b/src/events/dto/confirm-publish.dto.ts @@ -0,0 +1,7 @@ +import { IsString } from 'class-validator'; + +export class ConfirmPublishDto { + /** Wallet-signed XDR envelope returned from POST /events/:id/publish, unmodified. */ + @IsString() + signedXdr: string; +} diff --git a/src/events/dto/create-event.dto.spec.ts b/src/events/dto/create-event.dto.spec.ts new file mode 100644 index 0000000..8be968d --- /dev/null +++ b/src/events/dto/create-event.dto.spec.ts @@ -0,0 +1,50 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { CreateEventDto } from './create-event.dto'; + +function build(overrides: Record = {}) { + return plainToInstance(CreateEventDto, { + name: 'Radiohead Live', + category: 'CONCERTS', + venue: 'Amphitheater', + startsAt: '2026-09-14T20:00:00.000Z', + ...overrides, + }); +} + +describe('CreateEventDto', () => { + it('accepts a well-formed payload with only required fields', async () => { + const errors = await validate(build()); + expect(errors).toHaveLength(0); + }); + + it('accepts a valid maxResaleMultiplierBps and royaltyBps', async () => { + const errors = await validate( + build({ maxResaleMultiplierBps: 12_000, royaltyBps: 500 }), + ); + expect(errors).toHaveLength(0); + }); + + it('rejects a maxResaleMultiplierBps below 100% (would be a discount, not a cap)', async () => { + const errors = await validate(build({ maxResaleMultiplierBps: 9_000 })); + expect(errors.some((e) => e.property === 'maxResaleMultiplierBps')).toBe( + true, + ); + }); + + it('rejects a royaltyBps above 20%', async () => { + const errors = await validate(build({ royaltyBps: 2_500 })); + expect(errors.some((e) => e.property === 'royaltyBps')).toBe(true); + }); + + it('rejects a non-date startsAt', async () => { + const errors = await validate(build({ startsAt: 'not-a-date' })); + expect(errors.some((e) => e.property === 'startsAt')).toBe(true); + }); + + it('rejects an unrecognized category', async () => { + const errors = await validate(build({ category: 'SPACE_TRAVEL' })); + expect(errors.some((e) => e.property === 'category')).toBe(true); + }); +}); diff --git a/src/events/dto/create-event.dto.ts b/src/events/dto/create-event.dto.ts new file mode 100644 index 0000000..695a6e8 --- /dev/null +++ b/src/events/dto/create-event.dto.ts @@ -0,0 +1,48 @@ +import { Type } from 'class-transformer'; +import { + IsDate, + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + Min, + MinLength, +} from 'class-validator'; +import { Industry } from '@prisma/client'; + +export class CreateEventDto { + @IsString() + @MinLength(2) + name: string; + + @IsEnum(Industry) + category: Industry; + + @IsString() + @MinLength(1) + venue: string; + + @Type(() => Date) + @IsDate() + startsAt: Date; + + @IsOptional() + @Type(() => Date) + @IsDate() + endsAt?: Date; + + /** Basis points cap on resale price relative to face value (10000 = 100%). */ + @IsOptional() + @IsInt() + @Min(10_000) + @Max(50_000) + maxResaleMultiplierBps?: number; + + /** Basis points of every resale paid to the organizer as royalty. */ + @IsOptional() + @IsInt() + @Min(0) + @Max(2_000) + royaltyBps?: number; +} diff --git a/src/events/dto/create-ticket-type.dto.spec.ts b/src/events/dto/create-ticket-type.dto.spec.ts new file mode 100644 index 0000000..8c49e38 --- /dev/null +++ b/src/events/dto/create-ticket-type.dto.spec.ts @@ -0,0 +1,38 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { CreateTicketTypeDto } from './create-ticket-type.dto'; + +function build(overrides: Record = {}) { + return plainToInstance(CreateTicketTypeDto, { + name: 'GA', + price: '1000', + quantityTotal: 100, + ...overrides, + }); +} + +describe('CreateTicketTypeDto', () => { + it('accepts a well-formed payload', async () => { + expect(await validate(build())).toHaveLength(0); + }); + + it('rejects a zero quantityTotal', async () => { + const errors = await validate(build({ quantityTotal: 0 })); + expect(errors.some((e) => e.property === 'quantityTotal')).toBe(true); + }); + + it('rejects a negative quantityTotal', async () => { + const errors = await validate(build({ quantityTotal: -5 })); + expect(errors.some((e) => e.property === 'quantityTotal')).toBe(true); + }); + + it('rejects a non-integer quantityTotal', async () => { + const errors = await validate(build({ quantityTotal: 1.5 })); + expect(errors.some((e) => e.property === 'quantityTotal')).toBe(true); + }); + + it('rejects an empty name', async () => { + const errors = await validate(build({ name: '' })); + expect(errors.some((e) => e.property === 'name')).toBe(true); + }); +}); diff --git a/src/events/dto/create-ticket-type.dto.ts b/src/events/dto/create-ticket-type.dto.ts new file mode 100644 index 0000000..857e617 --- /dev/null +++ b/src/events/dto/create-ticket-type.dto.ts @@ -0,0 +1,15 @@ +import { IsInt, IsPositive, IsString, MinLength } from 'class-validator'; + +export class CreateTicketTypeDto { + @IsString() + @MinLength(1) + name: string; + + /** Face-value price in the settlement token's smallest unit, as a string to preserve i128 precision over JSON. */ + @IsString() + price: string; + + @IsInt() + @IsPositive() + quantityTotal: number; +} diff --git a/src/events/events.controller.ts b/src/events/events.controller.ts new file mode 100644 index 0000000..c753bb8 --- /dev/null +++ b/src/events/events.controller.ts @@ -0,0 +1,75 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { CurrentUserPayload } from '../auth/decorators/current-user.decorator'; +import { EventsService } from './events.service'; +import { CreateEventDto } from './dto/create-event.dto'; +import { CreateTicketTypeDto } from './dto/create-ticket-type.dto'; +import { ConfirmPublishDto } from './dto/confirm-publish.dto'; + +@Controller() +export class EventsController { + constructor(private readonly eventsService: EventsService) {} + + @Get('events') + findPublished() { + return this.eventsService.findPublished(); + } + + @Get('events/:eventId') + findOne(@Param('eventId') eventId: string) { + return this.eventsService.getWithOrg(eventId); + } + + @Get('organizations/:organizationId/events') + @UseGuards(JwtAuthGuard) + findForOrganization( + @CurrentUser() user: CurrentUserPayload, + @Param('organizationId') organizationId: string, + ) { + return this.eventsService.findForOrganization(user.userId, organizationId); + } + + @Post('organizations/:organizationId/events') + @UseGuards(JwtAuthGuard) + create( + @CurrentUser() user: CurrentUserPayload, + @Param('organizationId') organizationId: string, + @Body() dto: CreateEventDto, + ) { + return this.eventsService.create(user.userId, organizationId, dto); + } + + @Post('events/:eventId/ticket-types') + @UseGuards(JwtAuthGuard) + addTicketType( + @CurrentUser() user: CurrentUserPayload, + @Param('eventId') eventId: string, + @Body() dto: CreateTicketTypeDto, + ) { + return this.eventsService.addTicketType(user.userId, eventId, dto); + } + + @Post('events/:eventId/publish') + @UseGuards(JwtAuthGuard) + buildPublishTx( + @CurrentUser() user: CurrentUserPayload, + @Param('eventId') eventId: string, + ) { + return this.eventsService.buildPublishTx(user.userId, eventId); + } + + @Post('events/:eventId/confirm-publish') + @UseGuards(JwtAuthGuard) + confirmPublish( + @CurrentUser() user: CurrentUserPayload, + @Param('eventId') eventId: string, + @Body() dto: ConfirmPublishDto, + ) { + return this.eventsService.confirmPublish( + user.userId, + eventId, + dto.signedXdr, + ); + } +} diff --git a/src/events/events.module.ts b/src/events/events.module.ts new file mode 100644 index 0000000..c9a1bd3 --- /dev/null +++ b/src/events/events.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { OrganizationsModule } from '../organizations/organizations.module'; +import { StellarModule } from '../stellar/stellar.module'; +import { EventsController } from './events.controller'; +import { EventsService } from './events.service'; + +@Module({ + imports: [OrganizationsModule, StellarModule], + controllers: [EventsController], + providers: [EventsService], + exports: [EventsService], +}) +export class EventsModule {} diff --git a/src/events/events.service.spec.ts b/src/events/events.service.spec.ts new file mode 100644 index 0000000..96090cf --- /dev/null +++ b/src/events/events.service.spec.ts @@ -0,0 +1,188 @@ +import { BadRequestException } from '@nestjs/common'; + +// See tickets.service.spec.ts for why StellarService is mocked at the +// module level rather than imported for real. +jest.mock('../stellar/stellar.service', () => ({ StellarService: jest.fn() })); + +import { EventsService } from './events.service'; +import type { PrismaService } from '../prisma/prisma.service'; +import type { OrganizationsService } from '../organizations/organizations.service'; +import type { StellarService } from '../stellar/stellar.service'; + +describe('EventsService', () => { + let service: EventsService; + let prisma: { + event: { + create: jest.Mock; + update: jest.Mock; + findUnique: jest.Mock; + findMany: jest.Mock; + }; + }; + let organizations: { assertMember: jest.Mock }; + let stellar: { + buildCreateEventTx: jest.Mock; + submitSignedTransaction: jest.Mock; + }; + + beforeEach(() => { + prisma = { + event: { + create: jest.fn(), + update: jest.fn(), + findUnique: jest.fn(), + findMany: jest.fn(), + }, + }; + organizations = { assertMember: jest.fn().mockResolvedValue(undefined) }; + stellar = { + buildCreateEventTx: jest.fn().mockResolvedValue('unsigned-xdr'), + submitSignedTransaction: jest + .fn() + .mockResolvedValue({ result: null, txHash: '0xabc' }), + }; + + service = new EventsService( + prisma as unknown as PrismaService, + organizations as unknown as OrganizationsService, + stellar as unknown as StellarService, + ); + }); + + describe('buildPublishTx', () => { + it('refuses to publish an already-published event', async () => { + prisma.event.findUnique.mockResolvedValue({ + id: 'event-1', + organizationId: 'org-1', + status: 'PUBLISHED', + chainEventId: null, + organization: { stellarAccount: 'GORG' }, + }); + + await expect( + service.buildPublishTx('organizer-1', 'event-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(stellar.buildCreateEventTx).not.toHaveBeenCalled(); + }); + + it('refuses to re-publish an event that already reserved an on-chain id', async () => { + prisma.event.findUnique.mockResolvedValue({ + id: 'event-1', + organizationId: 'org-1', + status: 'DRAFT', + chainEventId: 99n, + organization: { stellarAccount: 'GORG' }, + }); + + await expect( + service.buildPublishTx('organizer-1', 'event-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('reserves a chain event id and builds create_event against the org account', async () => { + prisma.event.findUnique.mockResolvedValue({ + id: 'event-1', + organizationId: 'org-1', + status: 'DRAFT', + chainEventId: null, + name: 'Radiohead Live', + category: 'CONCERTS', + maxResaleMultiplierBps: 12_000, + royaltyBps: 500, + organization: { stellarAccount: 'GORG' }, + }); + prisma.event.update.mockResolvedValue({}); + + const { unsignedXdr } = await service.buildPublishTx( + 'organizer-1', + 'event-1', + ); + + expect(unsignedXdr).toBe('unsigned-xdr'); + expect(organizations.assertMember).toHaveBeenCalledWith( + 'org-1', + 'organizer-1', + ); + expect(stellar.buildCreateEventTx).toHaveBeenCalledWith( + expect.objectContaining({ + organizerPublicKey: 'GORG', + name: 'Radiohead Live', + category: 'CONCERTS', + maxResaleMultiplierBps: 12_000, + royaltyBps: 500, + }), + ); + }); + }); + + describe('confirmPublish', () => { + it('requires publish to have been called first', async () => { + prisma.event.findUnique.mockResolvedValue({ + id: 'event-1', + organizationId: 'org-1', + chainEventId: null, + organization: { stellarAccount: 'GORG' }, + }); + + await expect( + service.confirmPublish('organizer-1', 'event-1', 'signed-xdr'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('submits the signed transaction and marks the event published', async () => { + prisma.event.findUnique.mockResolvedValue({ + id: 'event-1', + organizationId: 'org-1', + chainEventId: 99n, + organization: { stellarAccount: 'GORG' }, + }); + prisma.event.update.mockResolvedValue({ + id: 'event-1', + status: 'PUBLISHED', + }); + + const event = await service.confirmPublish( + 'organizer-1', + 'event-1', + 'signed-xdr', + ); + + expect(stellar.submitSignedTransaction).toHaveBeenCalledWith( + 'signed-xdr', + ); + expect(prisma.event.update).toHaveBeenCalledWith({ + where: { id: 'event-1' }, + data: { status: 'PUBLISHED' }, + }); + expect(event.status).toBe('PUBLISHED'); + }); + }); + + describe('findForOrganization', () => { + it('requires membership before listing an organization’s events', async () => { + organizations.assertMember.mockRejectedValue(new Error('not a member')); + + await expect( + service.findForOrganization('outsider-1', 'org-1'), + ).rejects.toThrow('not a member'); + expect(prisma.event.findMany).not.toHaveBeenCalled(); + }); + + it('returns every event for the organization, drafts included', async () => { + prisma.event.findMany.mockResolvedValue([ + { id: 'event-1', status: 'DRAFT' }, + ]); + + const events = await service.findForOrganization('organizer-1', 'org-1'); + + expect(organizations.assertMember).toHaveBeenCalledWith( + 'org-1', + 'organizer-1', + ); + expect(prisma.event.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { organizationId: 'org-1' } }), + ); + expect(events).toEqual([{ id: 'event-1', status: 'DRAFT' }]); + }); + }); +}); diff --git a/src/events/events.service.ts b/src/events/events.service.ts new file mode 100644 index 0000000..b7e0ada --- /dev/null +++ b/src/events/events.service.ts @@ -0,0 +1,153 @@ +import { randomBytes } from 'node:crypto'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { EventStatus, Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { OrganizationsService } from '../organizations/organizations.service'; +import { StellarService } from '../stellar/stellar.service'; +import { CreateEventDto } from './dto/create-event.dto'; +import { CreateTicketTypeDto } from './dto/create-ticket-type.dto'; + +@Injectable() +export class EventsService { + constructor( + private readonly prisma: PrismaService, + private readonly organizations: OrganizationsService, + private readonly stellar: StellarService, + ) {} + + async create(userId: string, organizationId: string, dto: CreateEventDto) { + await this.organizations.assertMember(organizationId, userId); + return this.prisma.event.create({ + data: { + organizationId, + name: dto.name, + category: dto.category, + venue: dto.venue, + startsAt: dto.startsAt, + endsAt: dto.endsAt, + maxResaleMultiplierBps: dto.maxResaleMultiplierBps ?? 11_000, + royaltyBps: dto.royaltyBps ?? 500, + }, + }); + } + + async addTicketType( + userId: string, + eventId: string, + dto: CreateTicketTypeDto, + ) { + const event = await this.getWithOrg(eventId); + await this.organizations.assertMember(event.organizationId, userId); + return this.prisma.ticketType.create({ + data: { + eventId, + name: dto.name, + price: BigInt(dto.price), + quantityTotal: dto.quantityTotal, + }, + }); + } + + /** Step 1 of publishing: returns an unsigned XDR for the organizer's wallet to sign. */ + async buildPublishTx( + userId: string, + eventId: string, + ): Promise<{ unsignedXdr: string }> { + const event = await this.getWithOrg(eventId); + await this.organizations.assertMember(event.organizationId, userId); + if (event.status !== EventStatus.DRAFT) { + throw new BadRequestException('Only draft events can be published'); + } + if (event.chainEventId !== null) { + throw new BadRequestException( + 'This event already has a pending or confirmed on-chain id', + ); + } + + const chainEventId = await this.reserveChainEventId(eventId); + const unsignedXdr = await this.stellar.buildCreateEventTx({ + organizerPublicKey: event.organization.stellarAccount, + chainEventId, + name: event.name, + category: event.category, + maxResaleMultiplierBps: event.maxResaleMultiplierBps, + royaltyBps: event.royaltyBps, + }); + return { unsignedXdr }; + } + + /** Step 2: relays the organizer-signed XDR and marks the event published once it lands. */ + async confirmPublish(userId: string, eventId: string, signedXdr: string) { + const event = await this.getWithOrg(eventId); + await this.organizations.assertMember(event.organizationId, userId); + if (event.chainEventId === null) { + throw new BadRequestException('Call publish before confirm-publish'); + } + + await this.stellar.submitSignedTransaction(signedXdr); + return this.prisma.event.update({ + where: { id: eventId }, + data: { status: EventStatus.PUBLISHED }, + }); + } + + async getWithOrg(eventId: string) { + const event = await this.prisma.event.findUnique({ + where: { id: eventId }, + include: { organization: true }, + }); + if (!event) { + throw new NotFoundException('Event not found'); + } + return event; + } + + findPublished() { + return this.prisma.event.findMany({ + where: { status: EventStatus.PUBLISHED }, + include: { + ticketTypes: true, + organization: { select: { name: true, slug: true } }, + }, + orderBy: { startsAt: 'asc' }, + }); + } + + async findForOrganization(userId: string, organizationId: string) { + await this.organizations.assertMember(organizationId, userId); + return this.prisma.event.findMany({ + where: { organizationId }, + include: { ticketTypes: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + /** Picks a random u64 (well within Postgres's signed-bigint range) and reserves it on the event row. */ + private async reserveChainEventId(eventId: string): Promise { + for (let attempt = 0; attempt < 5; attempt++) { + const candidate = randomBytes(6).readUIntBE(0, 6); // 48 bits — Buffer#readUIntBE caps at 6 bytes + try { + await this.prisma.event.update({ + where: { id: eventId }, + data: { chainEventId: BigInt(candidate) }, + }); + return BigInt(candidate); + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + continue; // collision on the unique chainEventId column, retry + } + throw err; + } + } + throw new BadRequestException( + 'Could not allocate an on-chain event id, please retry', + ); + } +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 2289049..0000000 --- a/src/index.js +++ /dev/null @@ -1,238 +0,0 @@ -'use strict'; - -const express = require('express'); -const helmet = require('helmet'); -const config = require('./config'); -const logger = require('./logger'); -const cache = require('./services/cache'); -const priceOracle = require('./services/priceOracle'); -const priceRefreshJob = require('./jobs/priceRefresh'); -const webhookRetryWorker = require('./jobs/webhookRetryWorker'); -const airdropExpiryJob = require('./jobs/airdropExpiry'); -const { createLeaderElection } = require('./services/leaderElection'); -const { makeLeaderAwareJob } = require('./jobs/leaderAwareJob'); -const { warmCache } = require('./startup/cacheWarm'); -const buildCorsMiddleware = require('./middleware/cors'); -const buildRateLimit = require('./middleware/rateLimit'); -const { requestIdMiddleware } = require('./middleware/requestId'); -const { requireApiKey } = require('./middleware/auth'); -const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); -const pricesRouter = require('./routes/prices'); -const alertsRouter = require('./routes/alerts'); -const indexerRouter = require('./routes/indexer'); -const indexerPoller = require('./indexer/runtime'); -const keysRouter = require('./routes/keys'); -const webhooksRouter = require('./routes/webhooks'); -const airdropsRouter = require('./routes/airdrops'); -const apiDocsRouter = require('./routes/apiDocs'); - -const priceWebSocket = require('./ws/priceWebSocket'); - -// Wrap background jobs with leader-election coordination so that only one -// replica across the deployment runs each job at any given time. -// See README.md#leader-election for design, failover timing, and configuration. -const leaderElectionPriceRefresh = createLeaderElection('price_refresh'); -const leaderElectionWebhookRetry = createLeaderElection('webhook_retry'); -const leaderElectionAirdropExpiry = createLeaderElection('airdrop_expiry'); - -const wrappedPriceRefreshJob = makeLeaderAwareJob({ - job: priceRefreshJob, - jobName: 'price_refresh', - leaderElection: leaderElectionPriceRefresh, - logger, -}); - -const wrappedWebhookRetryWorker = makeLeaderAwareJob({ - job: webhookRetryWorker, - jobName: 'webhook_retry', - leaderElection: leaderElectionWebhookRetry, - logger, -}); - -const wrappedAirdropExpiryJob = makeLeaderAwareJob({ - job: airdropExpiryJob, - jobName: 'airdrop_expiry', - leaderElection: leaderElectionAirdropExpiry, - logger, -}); - -const app = express(); -let server = { - close(callback) { - if (callback) callback(); - }, -}; - -app.use(requestIdMiddleware); -app.use(helmet()); -app.use(buildCorsMiddleware(config.corsAllowedOrigins)); -app.use(express.json({ limit: config.airdrops.jsonMaxBytes })); - -app.get('/health', (req, res) => { - const redisConnected = cache.isConnected(); - const priceRefreshHealth = wrappedPriceRefreshJob.getHealth(); - const webhookWorkerHealth = wrappedWebhookRetryWorker.getHealth(); - const airdropExpiryHealth = wrappedAirdropExpiryJob.getHealth(); - - // Compute overall status: - // unhealthy – Redis is down, or a job is stalled past its grace period - // degraded – a job has not yet run but is still within its startup grace period - // ok – all dependencies healthy - // - // Note: a non-leader instance reports its jobs as not healthy (since they - // aren't running locally), but that's expected — the leader is doing the - // work. The health check distinguishes "not leader" from "stalled" via the - // `leader` field. - let status = 'ok'; - if (!redisConnected || !priceRefreshHealth.healthy || !webhookWorkerHealth.healthy) { - const jobsDegraded = - (!priceRefreshHealth.healthy && !priceRefreshHealth.stalled) || - (!webhookWorkerHealth.healthy && !webhookWorkerHealth.stalled); - status = (!redisConnected || priceRefreshHealth.stalled || webhookWorkerHealth.stalled) - ? 'unhealthy' - : jobsDegraded ? 'degraded' : 'unhealthy'; - } - - res.json({ - status, - timestamp: new Date().toISOString(), - redis_connected: redisConnected, - redis_unavailable: !redisConnected, - circuits: priceOracle.getCircuitStates(), - redis: { - connected: redisConnected, - }, - jobs: { - price_refresh: { - healthy: priceRefreshHealth.healthy, - last_success_at: priceRefreshHealth.lastSuccessAt - ? new Date(priceRefreshHealth.lastSuccessAt).toISOString() - : null, - last_error: priceRefreshHealth.lastError, - stalled: priceRefreshHealth.stalled, - leader: priceRefreshHealth.leader, - leader_instance_id: priceRefreshHealth.leaderInstanceId, - leader_since: priceRefreshHealth.leaderSince, - }, - webhook_retry_worker: { - healthy: webhookWorkerHealth.healthy, - last_success_at: webhookWorkerHealth.lastSuccessAt - ? new Date(webhookWorkerHealth.lastSuccessAt).toISOString() - : null, - last_error: webhookWorkerHealth.lastError, - stalled: webhookWorkerHealth.stalled, - leader: webhookWorkerHealth.leader, - leader_instance_id: webhookWorkerHealth.leaderInstanceId, - leader_since: webhookWorkerHealth.leaderSince, - }, - airdrop_expiry: { - healthy: airdropExpiryHealth.healthy, - last_success_at: airdropExpiryHealth.lastSuccessAt - ? new Date(airdropExpiryHealth.lastSuccessAt).toISOString() - : null, - last_error: airdropExpiryHealth.lastError, - stalled: airdropExpiryHealth.stalled, - leader: airdropExpiryHealth.leader, - leader_instance_id: airdropExpiryHealth.leaderInstanceId, - leader_since: airdropExpiryHealth.leaderSince, - }, - }, - database: { - configured: true, - checked: false, - status: 'unused', - }, - price_source_circuits: priceOracle.getSourceCircuitStates(), - leader_election: { - instance_id: config.leaderElection.instanceId, - lease_ttl_ms: config.leaderElection.leaseTtlMs, - renew_interval_ms: config.leaderElection.renewIntervalMs, - }, - }); -}); - -const globalApiLimit = buildRateLimit({ - windowSeconds: Math.floor(config.rateLimit.windowMs / 1000), - max: config.rateLimit.max, - keyPrefix: 'api', -}); - -app.use('/api/v1', globalApiLimit); -app.use('/api/v1', pricesRouter); -app.use('/api/v1', keysRouter); -app.use('/api/v1/alerts', requireApiKey()); -app.use('/api/v1', alertsRouter); -app.use('/api/v1', indexerRouter); -app.use('/api/v1', webhooksRouter); -app.use('/api/v1', airdropsRouter); -app.use('/api-docs', globalApiLimit); -app.use('/api-docs', apiDocsRouter); - -app.use(notFoundHandler); -app.use(errorHandler); - -function shutdown(signal) { - return async () => { - logger.info(`${signal} received, shutting down`); - - // Stop leader-aware jobs (releases leases gracefully) - await wrappedPriceRefreshJob.stop(); - await wrappedWebhookRetryWorker.stop(); - await wrappedAirdropExpiryJob.stop(); - - // Stop non-leader-elected services - indexerPoller.stop(); - require('./ws/PriceSubscriptionManager').stopHeartbeat(); - - if (server) server.close(); - await cache.disconnect(); - process.exit(0); - }; -} - -async function startServer() { - await warmCache(config.watchedAssets); - - server = app.listen(config.port, () => { - logger.info(`SmartDrop backend running on port ${config.port}`); - priceWebSocket.attach(server); - - // Start leader-aware background jobs. - // Each wrapped job starts a leader-election renewal loop. The underlying - // job (cron / setInterval) is only activated when this instance holds - // the leader lease. Non-leader instances remain ready to take over. - wrappedPriceRefreshJob.start(); - wrappedWebhookRetryWorker.start(); - wrappedAirdropExpiryJob.start(); - - // Indexer poller is not leader-elected (it uses its own cursor-based - // persistence in Redis and is safe for multiple replicas to run). - indexerPoller.start(); - }); - - return server; -} - -if (require.main === module) { - startServer().catch((err) => { - logger.error('Startup failed', { error: err.message }); - process.exit(1); - }); - - process.on('SIGTERM', shutdown('SIGTERM')); - process.on('SIGINT', shutdown('SIGINT')); -} - -module.exports = { - app, - server: server || { - close(callback) { - if (callback) callback(); - }, - }, - startServer, - // Exposed for testing - wrappedPriceRefreshJob, - wrappedWebhookRetryWorker, - wrappedAirdropExpiryJob, -}; diff --git a/src/indexer/eventParser.js b/src/indexer/eventParser.js deleted file mode 100644 index f338a3d..0000000 --- a/src/indexer/eventParser.js +++ /dev/null @@ -1,118 +0,0 @@ -const crypto = require('crypto'); -const { scValToNative } = require('stellar-sdk'); - -const EVENT_FIELDS = { - airdrop_created: ['airdrop_id', 'creator', 'token', 'total_amount', 'expiry_ledger'], - recipient_added: ['airdrop_id', 'recipient', 'amount'], - token_claimed: ['airdrop_id', 'recipient', 'amount', 'ledger'], - airdrop_expired: ['airdrop_id', 'unclaimed_amount'], -}; - -const EVENT_NAMES = Object.keys(EVENT_FIELDS); - -function toJsonSafe(value) { - if (typeof value === 'bigint') return value.toString(); - if (Buffer.isBuffer(value)) return value.toString('base64'); - if (Array.isArray(value)) return value.map(toJsonSafe); - if (value && typeof value === 'object') { - return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, toJsonSafe(val)])); - } - return value; -} - -function xdrBase64(scVal) { - if (!scVal || typeof scVal.toXDR !== 'function') return null; - return scVal.toXDR('base64'); -} - -function decodeScVal(scVal) { - if (scVal === undefined || scVal === null) return null; - return toJsonSafe(scValToNative(scVal)); -} - -function normalizeEventName(value) { - if (typeof value !== 'string') return null; - return EVENT_NAMES.includes(value) ? value : null; -} - -function dataFromValue(eventName, value, topicHintCount = 0) { - if (value && !Array.isArray(value) && typeof value === 'object') { - return value; - } - - const fields = EVENT_FIELDS[eventName]; - if (Array.isArray(value)) { - const valueFields = topicHintCount > 0 && value.length < fields.length - ? fields.slice(fields.length - value.length) - : fields; - - return Object.fromEntries(valueFields.map((field, index) => [field, value[index] ?? null])); - } - - return { value }; -} - -function mergeTopicHints(eventName, data, topics) { - const eventNameIndex = topics.findIndex((topic) => topic === eventName); - const topicHints = eventNameIndex >= 0 ? topics.slice(eventNameIndex + 1) : []; - const merged = { ...data }; - - if (merged.airdrop_id == null && topicHints[0] != null) merged.airdrop_id = topicHints[0]; - if (merged.recipient == null && topicHints[1] != null) merged.recipient = topicHints[1]; - - return merged; -} - -function eventId(event) { - if (event.id) return String(event.id); - const fallback = `${event.ledger}:${event.pagingToken}:${JSON.stringify(event.topic || [])}`; - return crypto.createHash('sha256').update(fallback).digest('hex'); -} - -function contractIdToString(contractId) { - if (!contractId) return null; - if (typeof contractId === 'string') return contractId; - if (typeof contractId.toString === 'function') return contractId.toString(); - return String(contractId); -} - -function parseContractEvent(event) { - const nativeTopics = (event.topic || []).map(decodeScVal); - const eventName = nativeTopics.map(normalizeEventName).find(Boolean); - - if (!eventName) return null; - - const decodedValue = decodeScVal(event.value); - const eventNameIndex = nativeTopics.findIndex((topic) => topic === eventName); - const topicHintCount = eventNameIndex >= 0 ? nativeTopics.length - eventNameIndex - 1 : 0; - const data = mergeTopicHints(eventName, dataFromValue(eventName, decodedValue, topicHintCount), nativeTopics); - - return { - id: eventId(event), - event_name: eventName, - type: event.type, - ledger: event.ledger, - ledger_closed_at: event.ledgerClosedAt || null, - paging_token: event.pagingToken || null, - contract_id: contractIdToString(event.contractId), - in_successful_contract_call: event.inSuccessfulContractCall !== false, - data, - decoded: { - topics: nativeTopics, - value: decodedValue, - }, - raw_xdr: { - topics: (event.topic || []).map(xdrBase64), - value: xdrBase64(event.value), - }, - indexed_at: new Date().toISOString(), - }; -} - -module.exports = { - EVENT_FIELDS, - EVENT_NAMES, - decodeScVal, - parseContractEvent, - toJsonSafe, -}; diff --git a/src/indexer/eventPoller.js b/src/indexer/eventPoller.js deleted file mode 100644 index 8546d6f..0000000 --- a/src/indexer/eventPoller.js +++ /dev/null @@ -1,142 +0,0 @@ -const { SorobanRpc } = require('stellar-sdk'); -const config = require('../config'); -const logger = require('../logger'); -const eventStore = require('./eventStore'); -const { parseContractEvent } = require('./eventParser'); - -class EventPoller { - constructor(options = {}) { - this.contractId = options.contractId ?? config.indexer.contractId; - this.pollIntervalMs = options.pollIntervalMs ?? config.indexer.pollIntervalMs; - this.pollLimit = options.pollLimit ?? config.indexer.pollLimit; - this.startLedger = options.startLedger ?? config.indexer.startLedger; - this.enabled = options.enabled ?? config.indexer.enabled; - this.store = options.store || eventStore; - this.logger = options.logger || logger; - this.server = options.server || new SorobanRpc.Server(options.rpcUrl || config.stellar.sorobanRpcUrl); - this.timer = null; - this.lastRun = null; - this.lastError = null; - this.latestLedger = null; - } - - isConfigured() { - return this.enabled && Boolean(this.contractId); - } - - getStatus() { - return { - enabled: this.enabled, - configured: this.isConfigured(), - running: this.timer !== null, - contract_id: this.contractId || null, - poll_interval_ms: this.pollIntervalMs, - poll_limit: this.pollLimit, - last_run: this.lastRun, - last_error: this.lastError, - latest_ledger: this.latestLedger, - }; - } - - async pollOnce() { - if (!this.isConfigured()) { - return { skipped: true, reason: 'SMARTDROP_CONTRACT_ID not configured' }; - } - - const previousLedger = await this.store.getLastLedger(null); - const startLedger = previousLedger == null - ? this.startLedger || 0 - : Math.max(Number(previousLedger) + 1, this.startLedger || 0); - - const response = await this.server.getEvents({ - startLedger, - filters: [ - { - type: 'contract', - contractIds: [this.contractId], - }, - ], - limit: this.pollLimit, - }); - - const rawEvents = response.events || []; - const parsedEvents = rawEvents.map(parseContractEvent).filter(Boolean); - - for (const event of parsedEvents) { - await this.store.saveEvent(event); - } - - // response.latestLedger is the chain's current tip, not how far this - // particular call actually got — if the RPC returned a full pollLimit - // batch, more matching events may exist beyond it. Jumping the cursor - // to the tip in that case permanently skips whatever wasn't returned, - // with no retry and no downstream reconciliation that could recover - // it (#115). Only safe to advance to the tip when this batch wasn't - // truncated; otherwise advance only past what was actually processed, - // so the next poll picks up right where this one left off. - const truncated = rawEvents.length >= this.pollLimit; - const eventLedgers = parsedEvents.map((event) => event.ledger); - const latestIndexedLedger = truncated - ? Math.max(previousLedger ?? 0, ...eventLedgers) - : Math.max(response.latestLedger || previousLedger || 0, ...eventLedgers); - - await this.store.setLastLedger(latestIndexedLedger); - - if (truncated) { - this.logger.warn('SmartDrop event poll truncated by pollLimit; more events pending next cycle', { - pollLimit: this.pollLimit, - indexed_events: parsedEvents.length, - resumed_from_ledger: latestIndexedLedger + 1, - }); - } - - this.latestLedger = response.latestLedger || null; - this.lastRun = new Date().toISOString(); - this.lastError = null; - - return { - skipped: false, - start_ledger: startLedger, - latest_ledger: response.latestLedger, - indexed_events: parsedEvents.length, - truncated, - }; - } - - start() { - if (this.timer || !this.enabled) return; - if (!this.contractId) { - this.logger.warn('SmartDrop indexer disabled: SMARTDROP_CONTRACT_ID is not configured'); - return; - } - - const run = async () => { - try { - const result = await this.pollOnce(); - this.logger.info('SmartDrop contract events indexed', result); - } catch (err) { - this.lastRun = new Date().toISOString(); - this.lastError = err.message; - this.logger.warn('SmartDrop event indexing failed', { error: err.message }); - } - }; - - run(); - this.timer = setInterval(run, this.pollIntervalMs); - if (typeof this.timer.unref === 'function') this.timer.unref(); - this.logger.info('SmartDrop event indexer started', { - contractId: this.contractId, - pollIntervalMs: this.pollIntervalMs, - }); - } - - stop() { - if (this.timer) { - clearInterval(this.timer); - this.timer = null; - this.logger.info('SmartDrop event indexer stopped'); - } - } -} - -module.exports = { EventPoller }; diff --git a/src/indexer/eventStore.js b/src/indexer/eventStore.js deleted file mode 100644 index 6b60511..0000000 --- a/src/indexer/eventStore.js +++ /dev/null @@ -1,200 +0,0 @@ -const cache = require('../services/cache'); - -const EVENT_IDS_KEY = 'indexer:contract_events:ids'; -const LAST_LEDGER_KEY = 'indexer:last_ledger'; -const AIRDROP_IDS_KEY = 'indexer:airdrops:ids'; - -function eventKey(id) { - return `indexer:contract_event:${id}`; -} - -function airdropKey(id) { - return `indexer:airdrop:${id}`; -} - -function recipientsKey(id) { - return `indexer:airdrop:${id}:recipients`; -} - -function claimsKey(address) { - return `indexer:recipient:${address}:claims`; -} - -async function getJsonList(key) { - return (await cache.get(key)) || []; -} - -async function setJsonList(key, list) { - await cache.set(key, list); -} - -function getAirdropId(event) { - return event && event.data ? event.data.airdrop_id : null; -} - -function getRecipient(event) { - return event && event.data ? event.data.recipient : null; -} - -async function getLastLedger(defaultLedger = 0) { - const saved = await cache.get(LAST_LEDGER_KEY); - if (saved === null || saved === undefined || saved === '') return defaultLedger; - const parsed = Number(saved); - return Number.isFinite(parsed) ? parsed : defaultLedger; -} - -async function setLastLedger(ledger) { - await cache.set(LAST_LEDGER_KEY, Number(ledger)); -} - -async function upsertAirdrop(event) { - const airdropId = getAirdropId(event); - if (!airdropId) return; - - const existing = (await cache.get(airdropKey(airdropId))) || { airdrop_id: airdropId }; - const next = { - ...existing, - updated_ledger: event.ledger, - updated_at: event.ledger_closed_at, - }; - - if (event.event_name === 'airdrop_created') { - Object.assign(next, { - status: 'created', - creator: event.data.creator ?? existing.creator ?? null, - token: event.data.token ?? existing.token ?? null, - total_amount: event.data.total_amount ?? existing.total_amount ?? null, - expiry_ledger: event.data.expiry_ledger ?? existing.expiry_ledger ?? null, - created_ledger: event.ledger, - created_at: event.ledger_closed_at, - }); - } - - if (event.event_name === 'token_claimed') { - next.status = existing.status === 'expired' ? 'expired' : 'active'; - } - - if (event.event_name === 'airdrop_expired') { - Object.assign(next, { - status: 'expired', - unclaimed_amount: event.data.unclaimed_amount ?? null, - expired_ledger: event.ledger, - expired_at: event.ledger_closed_at, - }); - } - - await cache.set(airdropKey(airdropId), next); - await cache.getClient().sadd(AIRDROP_IDS_KEY, airdropId); -} - -async function upsertRecipient(event) { - const airdropId = getAirdropId(event); - const recipient = getRecipient(event); - if (!airdropId || !recipient) return; - - const key = recipientsKey(airdropId); - const recipients = await getJsonList(key); - const existingIndex = recipients.findIndex((entry) => entry.recipient === recipient); - const existing = existingIndex >= 0 ? recipients[existingIndex] : { recipient }; - const next = { - ...existing, - airdrop_id: airdropId, - amount: event.data.amount ?? existing.amount ?? null, - updated_ledger: event.ledger, - updated_at: event.ledger_closed_at, - }; - - if (event.event_name === 'recipient_added') { - next.status = existing.status || 'pending'; - next.added_ledger = event.ledger; - } - - if (event.event_name === 'token_claimed') { - next.status = 'claimed'; - next.claimed_ledger = event.data.ledger ?? event.ledger; - next.claimed_at = event.ledger_closed_at; - } - - if (existingIndex >= 0) recipients[existingIndex] = next; - else recipients.push(next); - - await setJsonList(key, recipients); -} - -async function appendClaim(event) { - const recipient = getRecipient(event); - const airdropId = getAirdropId(event); - if (event.event_name !== 'token_claimed' || !recipient || !airdropId) return; - - const key = claimsKey(recipient); - const claims = await getJsonList(key); - if (!claims.some((claim) => claim.event_id === event.id)) { - claims.push({ - event_id: event.id, - airdrop_id: airdropId, - recipient, - amount: event.data.amount ?? null, - ledger: event.data.ledger ?? event.ledger, - claimed_at: event.ledger_closed_at, - }); - await setJsonList(key, claims); - } -} - -async function saveEvent(event) { - await cache.set(eventKey(event.id), event); - await cache.getClient().sadd(EVENT_IDS_KEY, event.id); - await upsertAirdrop(event); - await upsertRecipient(event); - await appendClaim(event); -} - -async function getAirdropStatus(airdropId) { - const status = await cache.get(airdropKey(airdropId)); - if (!status) return null; - - const recipients = await getAirdropRecipients(airdropId); - const claimed_count = recipients.filter((recipient) => recipient.status === 'claimed').length; - - return { - ...status, - recipients_count: recipients.length, - claimed_count, - pending_count: recipients.length - claimed_count, - }; -} - -async function getAirdropRecipients(airdropId) { - return getJsonList(recipientsKey(airdropId)); -} - -async function getRecipientClaims(address) { - return getJsonList(claimsKey(address)); -} - -async function getEventCount() { - const ids = await cache.getClient().smembers(EVENT_IDS_KEY); - return ids.length; -} - -async function getStats() { - const [lastLedger, eventsCount] = await Promise.all([ - getLastLedger(0), - getEventCount(), - ]); - - return { - last_ledger: lastLedger, - events_count: eventsCount, - }; -} - -module.exports = { - getAirdropRecipients, - getAirdropStatus, - getLastLedger, - getRecipientClaims, - getStats, - saveEvent, - setLastLedger, -}; diff --git a/src/indexer/runtime.js b/src/indexer/runtime.js deleted file mode 100644 index fc4e3d7..0000000 --- a/src/indexer/runtime.js +++ /dev/null @@ -1,3 +0,0 @@ -const { EventPoller } = require('./eventPoller'); - -module.exports = new EventPoller(); diff --git a/src/jobs/airdropExpiry.js b/src/jobs/airdropExpiry.js deleted file mode 100644 index 63766b5..0000000 --- a/src/jobs/airdropExpiry.js +++ /dev/null @@ -1,130 +0,0 @@ -'use strict'; - -const cron = require('node-cron'); -const airdropsService = require('../services/airdrops'); -const webhookDispatcher = require('../services/webhookDispatcher'); -const config = require('../config'); -const logger = require('../logger'); - -let scheduledTask = null; - -/** - * One reconciliation pass: scans every non-terminal airdrop and expires any - * whose expiry_ledger has passed the current Horizon ledger. Exported - * separately from start() so tests can drive a single tick deterministically - * instead of waiting on cron. - */ -async function tick() { - let currentLedger; - try { - currentLedger = await airdropsService.getCurrentLedger(); - } catch (err) { - // Matches priceOracle.js's graceful-degradation style: Horizon being - // temporarily unreachable is expected and recoverable — log and skip - // this cycle rather than crashing the job or throwing out of the cron - // callback. - logger.warn('Airdrop expiry check skipped, Horizon unreachable', { error: err.message }); - return; - } - - let expiredCount = 0; - let scannedCount = 0; - - for await (const batch of airdropsService.scanIds()) { - for (const id of batch) { - scannedCount += 1; - - let airdrop; - try { - airdrop = await airdropsService.get(id); - } catch (err) { - logger.error('Airdrop expiry check failed to read airdrop, skipping', { - airdrop_id: id, - error: err.message, - }); - continue; - } - - if (!airdrop || airdropsService.TERMINAL_STATUSES.has(airdrop.status)) continue; - if (!airdrop.expiry_ledger || airdrop.expiry_ledger > currentLedger) continue; - - // Cheap pre-filter above avoids an unnecessary Lua round trip for the - // (typically vast majority of) airdrops nowhere near expiry. - // markExpired re-checks status and expiry_ledger atomically — if this - // pre-filter read was stale, or another cycle/process already - // transitioned it, markExpired safely no-ops instead of double-firing. - let updated; - try { - updated = await airdropsService.markExpired(id, currentLedger); - } catch (err) { - logger.error('Airdrop expiry transition failed, skipping', { - airdrop_id: id, - error: err.message, - }); - continue; - } - if (!updated) continue; - - expiredCount += 1; - try { - await webhookDispatcher.dispatch({ - event_type: 'airdrop.failed', - event_id: `evt_airdrop_expired_${id}_${currentLedger}`, - data: { - airdrop_id: id, - reason: 'expired', - expiry_ledger: updated.expiry_ledger, - current_ledger: currentLedger, - }, - }); - } catch (err) { - // The transition already committed — the airdrop is correctly - // expired regardless of whether the webhook delivery attempt - // itself failed to enqueue. Losing this specific delivery on a - // dispatch-time error (as opposed to an individual subscriber's - // endpoint failing, which webhookDispatcher already retries) is an - // accepted gap here — see #84 for the broader non-atomic-writes - // theme this falls under. - logger.error('Airdrop expiry webhook dispatch failed', { - airdrop_id: id, - error: err.message, - }); - } - } - } - - logger.info('Airdrop expiry check completed', { - currentLedger, - scanned: scannedCount, - expired: expiredCount, - }); -} - -function start() { - if (scheduledTask) return; - - const intervalSeconds = config.airdrops.expiryCheckIntervalSeconds; - const cronExpression = `*/${intervalSeconds} * * * * *`; - - scheduledTask = cron.schedule( - cronExpression, - () => { - tick().catch((err) => { - logger.error('Airdrop expiry check failed', { error: err.message }); - }); - }, - { scheduled: true }, - ); - - logger.info('Airdrop expiry job started', { intervalSeconds }); -} - -function stop() { - if (scheduledTask) { - scheduledTask.stop(); - scheduledTask = null; - logger.info('Airdrop expiry job stopped'); - } -} - -module.exports = { start, stop, tick }; diff --git a/src/jobs/leaderAwareJob.js b/src/jobs/leaderAwareJob.js deleted file mode 100644 index f9b5d0b..0000000 --- a/src/jobs/leaderAwareJob.js +++ /dev/null @@ -1,205 +0,0 @@ -'use strict'; - -/** - * Leader-aware job wrapper. - * - * Wraps a job module (e.g. priceRefresh, webhookRetryWorker, airdropExpiry) - * with leader-election coordination so that the underlying job's scheduled - * work only actually executes on the instance that currently holds the - * Redis-based leader lease. - * - * Non-leader instances remain ready to take over if the current leader's - * lease expires. Leadership state transitions are logged clearly for - * debugging in production. - * - * Usage: - * const leaderElection = require('../services/leaderElection'); - * const priceRefreshJob = require('./priceRefresh'); - * const wrappedJob = makeLeaderAwareJob({ - * job: priceRefreshJob, - * jobName: 'price_refresh', - * leaderElection: leaderElection.createLeaderElection('price_refresh'), - * logger: require('../logger'), - * }); - * wrappedJob.start(); // only starts underlying job if leader - * wrappedJob.stop(); // stops underlying job and releases lease - * wrappedJob.getHealth(); // includes leadership info - */ - -function makeLeaderAwareJob({ job, jobName, leaderElection, logger }) { - let underlyingStarted = false; - let manualStop = false; - let leadershipLostWhileRunning = false; - - /** - * Handle acquiring leadership: start the underlying job. - */ - function onLeadershipAcquired() { - if (manualStop) return; - if (!underlyingStarted) { - logger.info('Acting as leader — starting scheduled job', { job: jobName }); - job.start(); - underlyingStarted = true; - } else if (leadershipLostWhileRunning) { - // We lost leadership briefly and regained it — the underlying job was - // stopped when we lost it, so restart it. - logger.info('Re-acquired leadership — restarting scheduled job', { job: jobName }); - job.start(); - leadershipLostWhileRunning = false; - } - } - - /** - * Handle losing leadership: stop the underlying job immediately. - */ - function onLeadershipLost() { - if (underlyingStarted) { - logger.warn('Lost leadership — stopping scheduled job', { job: jobName }); - job.stop(); - underlyingStarted = false; - leadershipLostWhileRunning = true; - } - } - - /** - * Start the leader-election renewal loop. - * - * The underlying job's actual execution (cron / setInterval) is only - * started when this instance acquires the leader lease. Non-leader - * instances run only the renewal loop, staying ready to take over. - */ - function start() { - manualStop = false; - leadershipLostWhileRunning = false; - - // Register a callback on the leader election to react to state changes. - // We wrap the original startRenewLoop to also monitor transitions. - const origIsLeader = leaderElection.isLeader; - const origTryAcquire = leaderElection.tryAcquire; - const origRenew = leaderElection.renew; - - // Patch the leaderElection to notify us on state changes - let wasLeader = false; - - const checkLeader = () => { - const isLeaderNow = leaderElection.isLeader(); - if (isLeaderNow && !wasLeader) { - onLeadershipAcquired(); - } else if (!isLeaderNow && wasLeader) { - onLeadershipLost(); - } - wasLeader = isLeaderNow; - }; - - // Override isLeader to include our reactivity - const originalStartRenewLoop = leaderElection.startRenewLoop.bind(leaderElection); - const originalStopRenewLoop = leaderElection.stopRenewLoop.bind(leaderElection); - - // Start the renewal loop (which will call tryAcquire immediately) - leaderElection.startRenewLoop = () => { - originalStartRenewLoop(); - - // Also poll periodically to detect leadership transitions - // (the renewal loop already does this, but we hook into it) - logger.info('Leader-aware job started — awaiting leadership', { job: jobName, instanceId: leaderElection.instanceId }); - }; - - leaderElection.stopRenewLoop = async () => { - await originalStopRenewLoop(); - if (underlyingStarted) { - job.stop(); - underlyingStarted = false; - } - }; - - // Check leadership state on a short interval to react quickly - // to transitions detected by the renewal loop - const checkInterval = setInterval(() => { - checkLeader(); - }, Math.min(leaderElection.renewIntervalMs || 5000, 2000)); - - if (typeof checkInterval.unref === 'function') { - checkInterval.unref(); - } - - // Store cleanup - leaderElection._checkInterval = checkInterval; - - // Initial check after a short delay to let the first acquire complete - setTimeout(() => checkLeader(), 500); - - // Also call startRenewLoop - leaderElection.startRenewLoop(); - - // Patch the tryAcquire to trigger our callback - const superTryAcquire = leaderElection.tryAcquire; - leaderElection.tryAcquire = async (...args) => { - const result = await superTryAcquire(...args); - checkLeader(); - return result; - }; - - const superRenew = leaderElection.renew; - leaderElection.renew = async (...args) => { - const result = await superRenew(...args); - checkLeader(); - return result; - }; - } - - /** - * Stop the leader-election loop and the underlying job. - */ - async function stop() { - manualStop = true; - if (leaderElection._checkInterval) { - clearInterval(leaderElection._checkInterval); - leaderElection._checkInterval = null; - } - - // Release the lease and stop the renewal loop - await leaderElection.stopRenewLoop(); - - if (underlyingStarted) { - job.stop(); - underlyingStarted = false; - } - - logger.info('Leader-aware job stopped', { job: jobName }); - } - - /** - * Returns health info including leadership status. - */ - function getHealth() { - const baseHealth = typeof job.getHealth === 'function' ? job.getHealth() : {}; - const leaderState = leaderElection.getState(); - - return { - ...baseHealth, - leader: leaderState.isLeader, - leaderInstanceId: leaderState.instanceId, - leaderSince: leaderState.acquiredAt, - lockKey: leaderState.lockKey, - }; - } - - /** - * Get the underlying leader election instance (useful for tests). - */ - function getLeaderElection() { - return leaderElection; - } - - return { - start, - stop, - getHealth, - getLeaderElection, - jobName, - underlyingJob: job, - }; -} - -module.exports = { makeLeaderAwareJob }; - diff --git a/src/jobs/priceRefresh.js b/src/jobs/priceRefresh.js deleted file mode 100644 index 35feae3..0000000 --- a/src/jobs/priceRefresh.js +++ /dev/null @@ -1,86 +0,0 @@ -const cron = require('node-cron'); -const priceOracle = require('../services/priceOracle'); -const alertsService = require('../services/alerts'); -const subscriptionManager = require('../ws/PriceSubscriptionManager'); -const config = require('../config'); -const logger = require('../logger'); - -let scheduledTask = null; - -const health = { - startedAt: null, - lastSuccessAt: null, - lastError: null, - running: false, -}; - -function start() { - const intervalSeconds = config.price.refreshInterval; - const cronExpression = `*/${intervalSeconds} * * * * *`; - health.startedAt = Date.now(); - - scheduledTask = cron.schedule(cronExpression, async () => { - try { - logger.info('Starting scheduled price refresh'); - const freshPrices = await priceOracle.refreshAllCachedPrices(); - await alertsService.evaluateAll(); - if (freshPrices && Object.keys(freshPrices).length > 0) { - subscriptionManager.notifyPriceUpdates(freshPrices); - } - health.lastSuccessAt = Date.now(); - health.lastError = null; - } catch (err) { - logger.error('Scheduled price refresh failed', { error: err.message }); - health.lastError = err.message; - } - }, { - scheduled: true, - }); - - logger.info('Price refresh job started', { intervalSeconds }); -} - -function stop() { - if (scheduledTask) { - scheduledTask.stop(); - scheduledTask = null; - health.startedAt = null; - logger.info('Price refresh job stopped'); - } -} - -/** - * Returns the current health state of the price-refresh job. - * - * Grace period: a job that has never run since startup is not considered - * stalled until at least one full interval has elapsed. - * - * @returns {{ healthy: boolean, lastSuccessAt: number|null, lastError: string|null, stalled: boolean }} - */ -function getHealth() { - if (!health.startedAt) { - return { healthy: false, lastSuccessAt: null, lastError: null, stalled: false }; - } - - const intervalMs = (config.price.refreshInterval || 30) * 1000; - // Grace period: allow 2× the interval before flagging as stalled - const gracePeriodMs = intervalMs * 2; - const age = Date.now() - health.startedAt; - const inGrace = age < gracePeriodMs; - - if (health.lastSuccessAt === null) { - // Has not run yet — only healthy while inside the grace window - return { healthy: inGrace, lastSuccessAt: null, lastError: health.lastError, stalled: !inGrace }; - } - - const timeSinceSuccess = Date.now() - health.lastSuccessAt; - const stalled = timeSinceSuccess > gracePeriodMs; - return { - healthy: !stalled, - lastSuccessAt: health.lastSuccessAt, - lastError: health.lastError, - stalled, - }; -} - -module.exports = { start, stop, getHealth }; diff --git a/src/jobs/webhookRetryWorker.js b/src/jobs/webhookRetryWorker.js deleted file mode 100644 index d716137..0000000 --- a/src/jobs/webhookRetryWorker.js +++ /dev/null @@ -1,95 +0,0 @@ -'use strict'; - -const config = require('../config'); -const logger = require('../logger'); -const dispatcher = require('../services/webhookDispatcher'); -const deliveryRepo = require('../repositories/deliveryRepository'); - -let timer = null; -let running = false; - -const health = { - startedAt: null, - lastSuccessAt: null, - lastError: null, -}; - -async function tick() { - if (running) return; - running = true; - try { - const ids = await deliveryRepo.popDueRetries(Date.now(), config.webhooks.retryBatchSize); - if (ids.length === 0) { - // An empty poll is still a successful tick - health.lastSuccessAt = Date.now(); - health.lastError = null; - return; - } - logger.info('Processing webhook retries', { count: ids.length }); - for (const id of ids) { - try { - await dispatcher.attempt(id); - } catch (err) { - logger.error('Retry attempt failed', { delivery_id: id, error: err.message }); - } - } - health.lastSuccessAt = Date.now(); - health.lastError = null; - } catch (err) { - logger.error('Webhook retry worker tick failed', { error: err.message }); - health.lastError = err.message; - } finally { - running = false; - } -} - -function start() { - if (timer) return; - const interval = config.webhooks.retryPollMs; - health.startedAt = Date.now(); - timer = setInterval(tick, interval); - if (typeof timer.unref === 'function') timer.unref(); - logger.info('Webhook retry worker started', { intervalMs: interval }); -} - -function stop() { - if (timer) { - clearInterval(timer); - timer = null; - health.startedAt = null; - logger.info('Webhook retry worker stopped'); - } -} - -/** - * Returns the current health state of the webhook retry worker. - * - * Grace period: allow 2× the poll interval before flagging as stalled. - * - * @returns {{ healthy: boolean, lastSuccessAt: number|null, lastError: string|null, stalled: boolean }} - */ -function getHealth() { - if (!health.startedAt) { - return { healthy: false, lastSuccessAt: null, lastError: null, stalled: false }; - } - - const intervalMs = (config.webhooks.retryPollMs || 5000); - const gracePeriodMs = intervalMs * 2; - const age = Date.now() - health.startedAt; - const inGrace = age < gracePeriodMs; - - if (health.lastSuccessAt === null) { - return { healthy: inGrace, lastSuccessAt: null, lastError: health.lastError, stalled: !inGrace }; - } - - const timeSinceSuccess = Date.now() - health.lastSuccessAt; - const stalled = timeSinceSuccess > gracePeriodMs; - return { - healthy: !stalled, - lastSuccessAt: health.lastSuccessAt, - lastError: health.lastError, - stalled, - }; -} - -module.exports = { start, stop, tick, getHealth }; diff --git a/src/logger.js b/src/logger.js deleted file mode 100644 index 23238e5..0000000 --- a/src/logger.js +++ /dev/null @@ -1,116 +0,0 @@ -const winston = require('winston'); -const DailyRotateFile = require('winston-daily-rotate-file'); -const { name: serviceName, version } = require('../package.json'); -const { requestContext } = require('./middleware/requestId'); - -// ==================== LOG LEVEL ==================== -const getLogLevel = () => { - if (process.env.LOG_LEVEL) { - return process.env.LOG_LEVEL; - } - const env = process.env.NODE_ENV || 'development'; - if (env === 'production') return 'info'; - if (env === 'test') return 'warn'; - return 'debug'; -}; - -// ==================== REDACTION ==================== -const redactFormat = winston.format((info) => { - const sensitiveKeys = ['apikey', 'privatekey', 'secret', 'token']; - - const redactValue = (value, key) => { - if (typeof value !== 'string') return '[REDACTED]'; - if (key.toLowerCase().includes('secret') && value.startsWith('whsec_')) { - return 'whsec_****'; - } - return '[REDACTED]'; - }; - - const redact = (obj) => { - if (!obj || typeof obj !== 'object') return obj; - - for (const key of Object.keys(obj)) { - const lowerKey = key.toLowerCase(); - const isSensitive = sensitiveKeys.some(k => lowerKey.includes(k)); - - if (isSensitive) { - obj[key] = redactValue(obj[key], key); - } else if (typeof obj[key] === 'object') { - redact(obj[key]); - } - } - return obj; - }; - - return redact(info); -}); - -// ==================== FORMAT DECISION ==================== -const env = process.env.NODE_ENV || 'development'; -const logFormat = process.env.LOG_FORMAT || (env === 'production' ? 'json' : 'pretty'); -const useJsonFormat = logFormat === 'json'; - -// ==================== REQUEST CONTEXT ==================== -const requestIdFormat = winston.format((info) => { - info.requestId = requestContext.getStore()?.requestId ?? 'system'; - return info; -}); - -// ==================== BASE FORMATS ==================== -const baseFormats = [ - winston.format.timestamp({ format: () => new Date().toISOString() }), - winston.format.errors({ stack: true }), - requestIdFormat(), - redactFormat(), -]; - -// ==================== JSON FORMAT ==================== -const jsonFormat = winston.format.combine( - ...baseFormats, - winston.format.json() -); - -// ==================== PRETTY FORMAT ==================== -const prettyFormat = winston.format.combine( - ...baseFormats, - winston.format.colorize(), - winston.format.printf(({ timestamp, level, message, stack, ...meta }) => { - const { service, version: ver, ...rest } = meta; - const metaStr = Object.keys(rest).length - ? ` ${JSON.stringify(rest)}` - : ''; - - return `${timestamp} [${level}] [${service}@${ver}] ${message}${metaStr}${stack ? `\n${stack}` : ''}`; - }) -); - -// ==================== TRANSPORTS ==================== -const transports = [ - new winston.transports.Console({ - format: useJsonFormat ? jsonFormat : prettyFormat - }) -]; - -// Optional file logging -if (process.env.LOG_FILE_PATH) { - transports.push( - new DailyRotateFile({ - filename: `${process.env.LOG_FILE_PATH}/application-%DATE%.log`, - datePattern: 'YYYY-MM-DD', - zippedArchive: true, - maxSize: '20m', - maxFiles: '14d', - format: jsonFormat - }) - ); -} - -// ==================== LOGGER ==================== -const logger = winston.createLogger({ - level: getLogLevel(), - defaultMeta: { service: serviceName, version }, - transports, - exitOnError: false -}); - -module.exports = logger; \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..1f9a01c --- /dev/null +++ b/src/main.ts @@ -0,0 +1,26 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import helmet from 'helmet'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const config = app.get(ConfigService); + + app.use(helmet()); + app.enableCors({ + origin: config.getOrThrow('APP_URL'), + credentials: true, + }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + await app.listen(config.getOrThrow('PORT')); +} +void bootstrap(); diff --git a/src/middleware/auth.js b/src/middleware/auth.js deleted file mode 100644 index 43da006..0000000 --- a/src/middleware/auth.js +++ /dev/null @@ -1,45 +0,0 @@ -const apiKeys = require('../services/apiKeys'); -const logger = require('../logger'); -const AppError = require('../errors/AppError'); - -function extractBearerToken(header) { - if (!header || typeof header !== 'string') return null; - const match = header.match(/^Bearer\s+(.+)$/i); - return match ? match[1].trim() : null; -} - -function hasScopes(apiKey, requiredScopes) { - if (!requiredScopes.length) return true; - const scopes = new Set(apiKey.scopes || []); - return requiredScopes.every((scope) => scopes.has(scope)); -} - -function requireApiKey(options = {}) { - const requiredScopes = options.scopes || []; - - return async (req, res, next) => { - const token = extractBearerToken(req.get('authorization')); - if (!token) { - return next(new AppError('UNAUTHORIZED', 'Missing or invalid API key', 401)); - } - - try { - const apiKey = await apiKeys.validateApiKey(token); - if (!apiKey || !hasScopes(apiKey, requiredScopes)) { - logger.warn('Rejected API key authentication', { key_prefix: token.slice(0, 8) }); - return next(new AppError('UNAUTHORIZED', 'Missing or invalid API key', 401)); - } - - req.apiKey = apiKey; - return next(); - } catch (err) { - logger.error('API key authentication failed', { error: err.message }); - return next(new AppError('UNAUTHORIZED', 'Missing or invalid API key', 401)); - } - }; -} - -module.exports = { - requireApiKey, - extractBearerToken, -}; diff --git a/src/middleware/cors.js b/src/middleware/cors.js deleted file mode 100644 index 6d779ed..0000000 --- a/src/middleware/cors.js +++ /dev/null @@ -1,18 +0,0 @@ -const cors = require('cors'); - -function buildCorsMiddleware(allowedOrigins) { - return cors({ - origin(origin, callback) { - if (!origin || allowedOrigins.includes(origin)) return callback(null, true); - const err = new Error(`Origin ${origin} not allowed`); - err.status = 403; - callback(err); - }, - credentials: true, - methods: ['GET', 'POST', 'DELETE', 'PATCH', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'], - maxAge: 86400, - }); -} - -module.exports = buildCorsMiddleware; diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js deleted file mode 100644 index 15ee2f7..0000000 --- a/src/middleware/errorHandler.js +++ /dev/null @@ -1,39 +0,0 @@ -'use strict'; - -const AppError = require('../errors/AppError'); -const logger = require('../logger'); - -function notFoundHandler(req, _res, next) { - next(new AppError('NOT_FOUND', 'Resource does not exist', 404, { path: req.originalUrl })); -} - -function errorHandler(err, req, res, _next) { - const isAppError = err instanceof AppError; - const isPayloadTooLarge = !isAppError && err.type === 'entity.too.large'; - let status = 500; - let code = 'INTERNAL_ERROR'; - let message = 'An unexpected error occurred'; - - if (isAppError) { - status = err.statusCode; - code = err.code; - message = err.message; - } else if (isPayloadTooLarge) { - status = 413; - code = 'PAYLOAD_TOO_LARGE'; - message = 'Request body is too large'; - } - - if ((!isAppError && !isPayloadTooLarge) || status >= 500) { - logger.error('Unhandled error', { error: err.message, stack: err.stack, request_id: req.id }); - } - - const error = { code, message, request_id: req.id }; - if (isAppError && err.details && Object.keys(err.details).length > 0) { - error.details = err.details; - } - - res.status(status).json({ error }); -} - -module.exports = { errorHandler, notFoundHandler }; diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js deleted file mode 100644 index d9b99aa..0000000 --- a/src/middleware/rateLimit.js +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; - -const cache = require('../services/cache'); -const logger = require('../logger'); -const AppError = require('../errors/AppError'); - -/** - * Fixed-window rate limiter backed by Redis INCR + EXPIRE. - * Fails open if Redis is unreachable so a cache outage cannot lock out users. - */ -function buildRateLimit({ windowSeconds, max, keyPrefix }) { - if (!Number.isFinite(windowSeconds) || windowSeconds <= 0) { - throw new Error('windowSeconds must be a positive number'); - } - if (!Number.isFinite(max) || max <= 0) { - throw new Error('max must be a positive number'); - } - if (!keyPrefix || typeof keyPrefix !== 'string') { - throw new Error('keyPrefix is required'); - } - - return async function rateLimit(req, res, next) { - const identifier = req.ip || req.connection?.remoteAddress || 'unknown'; - const bucket = Math.floor(Date.now() / 1000 / windowSeconds); - const key = `ratelimit:${keyPrefix}:${identifier}:${bucket}`; - - try { - const redis = cache.getClient(); - const count = await redis.incr(key); - if (count === 1) { - await redis.expire(key, windowSeconds); - } - const remaining = Math.max(0, max - count); - const resetAt = (bucket + 1) * windowSeconds; - const retryAfterSeconds = Math.max(1, resetAt - Math.floor(Date.now() / 1000)); - res.setHeader('X-RateLimit-Limit', String(max)); - res.setHeader('X-RateLimit-Remaining', String(remaining)); - res.setHeader('X-RateLimit-Reset', String(resetAt)); - if (count > max) { - res.setHeader('Retry-After', String(retryAfterSeconds)); - return next(new AppError( - 'RATE_LIMITED', - `Rate limit of ${max} requests per ${windowSeconds}s exceeded`, - 429, - { limit: max, window_seconds: windowSeconds, retry_after_seconds: retryAfterSeconds }, - )); - } - return next(); - } catch (err) { - logger.warn('Rate limit fail-open due to cache error', { error: err.message }); - return next(); - } - }; -} - -module.exports = buildRateLimit; diff --git a/src/middleware/requestId.js b/src/middleware/requestId.js deleted file mode 100644 index 737f863..0000000 --- a/src/middleware/requestId.js +++ /dev/null @@ -1,41 +0,0 @@ -'use strict'; - -const crypto = require('node:crypto'); -const { AsyncLocalStorage } = require('node:async_hooks'); - -const requestContext = new AsyncLocalStorage(); - -function nanoid(size = 21) { - const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-'; - const bytes = crypto.randomBytes(size); - let id = ''; - for (const byte of bytes) id += alphabet[byte & 63]; - return id; -} - -function requestIdMiddleware(req, res, next) { - req.id = req.get('x-request-id') || `req_${nanoid()}`; - res.setHeader('X-Request-ID', req.id); - - const originalJson = res.json.bind(res); - res.json = (body) => { - if ( - body && - typeof body === 'object' && - !Array.isArray(body) && - !Object.prototype.hasOwnProperty.call(body, 'request_id') && - !Object.prototype.hasOwnProperty.call(body, 'error') - ) { - body.request_id = req.id; - } - return originalJson(body); - }; - - requestContext.run({ requestId: req.id }, next); -} - -module.exports = { - requestIdMiddleware, - requestContext, - nanoid, -}; diff --git a/src/middleware/validate.js b/src/middleware/validate.js deleted file mode 100644 index 4e5f0e4..0000000 --- a/src/middleware/validate.js +++ /dev/null @@ -1,35 +0,0 @@ -'use strict'; - -const AppError = require('../errors/AppError'); - -function flattenZodIssues(error) { - return error.issues.reduce((fields, issue) => { - const path = issue.path.length > 0 ? issue.path.join('.') : '_root'; - fields[path] = fields[path] || []; - fields[path].push(issue.message); - return fields; - }, {}); -} - -function validate(schema, source = 'body') { - return (req, _res, next) => { - const result = schema.safeParse(req[source] ?? {}); - if (!result.success) { - return next(new AppError('VALIDATION_ERROR', 'Validation failed', 400, { - fields: flattenZodIssues(result.error), - })); - } - - req.validated = { - ...(req.validated || {}), - [source]: result.data, - }; - req[source] = result.data; - return next(); - }; -} - -module.exports = { - flattenZodIssues, - validate, -}; diff --git a/src/organizations/dto/create-organization.dto.spec.ts b/src/organizations/dto/create-organization.dto.spec.ts new file mode 100644 index 0000000..212d5f5 --- /dev/null +++ b/src/organizations/dto/create-organization.dto.spec.ts @@ -0,0 +1,42 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { CreateOrganizationDto } from './create-organization.dto'; + +const VALID_KEY = 'GBAHZWO3UI3GAHPQCPSW6IR5N7HJ4UBRZNAFMSYB6DAKVNHQDOZIV2YJ'; + +function build(overrides: Record = {}) { + return plainToInstance(CreateOrganizationDto, { + name: 'Test Org', + slug: 'test-org', + industry: 'CONCERTS', + stellarAccount: VALID_KEY, + ...overrides, + }); +} + +describe('CreateOrganizationDto', () => { + it('accepts a well-formed payload', async () => { + const errors = await validate(build()); + expect(errors).toHaveLength(0); + }); + + it('rejects an uppercase or spaced slug', async () => { + const errors = await validate(build({ slug: 'Not A Slug' })); + expect(errors.some((e) => e.property === 'slug')).toBe(true); + }); + + it('rejects an industry outside the enum', async () => { + const errors = await validate(build({ industry: 'CIRCUS' })); + expect(errors.some((e) => e.property === 'industry')).toBe(true); + }); + + it('rejects a name shorter than 2 characters', async () => { + const errors = await validate(build({ name: 'A' })); + expect(errors.some((e) => e.property === 'name')).toBe(true); + }); + + it('rejects an invalid stellarAccount', async () => { + const errors = await validate(build({ stellarAccount: 'not-valid' })); + expect(errors.some((e) => e.property === 'stellarAccount')).toBe(true); + }); +}); diff --git a/src/organizations/dto/create-organization.dto.ts b/src/organizations/dto/create-organization.dto.ts new file mode 100644 index 0000000..23240ca --- /dev/null +++ b/src/organizations/dto/create-organization.dto.ts @@ -0,0 +1,20 @@ +import { IsEnum, IsString, Matches, MinLength } from 'class-validator'; +import { Industry } from '@prisma/client'; +import { IsStellarPublicKey } from '../../common/decorators/is-stellar-public-key.decorator'; + +export class CreateOrganizationDto { + @IsString() + @MinLength(2) + name: string; + + @Matches(/^[a-z0-9]+(-[a-z0-9]+)*$/, { + message: 'slug must be lowercase, alphanumeric, and hyphen-separated', + }) + slug: string; + + @IsEnum(Industry) + industry: Industry; + + @IsStellarPublicKey() + stellarAccount: string; +} diff --git a/src/organizations/organizations.controller.ts b/src/organizations/organizations.controller.ts new file mode 100644 index 0000000..b5a4abc --- /dev/null +++ b/src/organizations/organizations.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { CurrentUserPayload } from '../auth/decorators/current-user.decorator'; +import { OrganizationsService } from './organizations.service'; +import { CreateOrganizationDto } from './dto/create-organization.dto'; + +@Controller('organizations') +@UseGuards(JwtAuthGuard) +export class OrganizationsController { + constructor(private readonly organizationsService: OrganizationsService) {} + + @Post() + create( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: CreateOrganizationDto, + ) { + return this.organizationsService.create(user.userId, dto); + } + + @Get('mine') + findMine(@CurrentUser() user: CurrentUserPayload) { + return this.organizationsService.findMine(user.userId); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.organizationsService.findOne(id); + } +} diff --git a/src/organizations/organizations.module.ts b/src/organizations/organizations.module.ts new file mode 100644 index 0000000..7f4b9f3 --- /dev/null +++ b/src/organizations/organizations.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { OrganizationsController } from './organizations.controller'; +import { OrganizationsService } from './organizations.service'; + +@Module({ + controllers: [OrganizationsController], + providers: [OrganizationsService], + exports: [OrganizationsService], +}) +export class OrganizationsModule {} diff --git a/src/organizations/organizations.service.spec.ts b/src/organizations/organizations.service.spec.ts new file mode 100644 index 0000000..82043f5 --- /dev/null +++ b/src/organizations/organizations.service.spec.ts @@ -0,0 +1,105 @@ +import { + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { OrganizationsService } from './organizations.service'; +import type { PrismaService } from '../prisma/prisma.service'; + +describe('OrganizationsService', () => { + let service: OrganizationsService; + let prisma: { + organization: { + findUnique: jest.Mock; + findMany: jest.Mock; + create: jest.Mock; + }; + organizationMember: { create: jest.Mock; findUnique: jest.Mock }; + user: { updateMany: jest.Mock }; + $transaction: jest.Mock; + }; + + beforeEach(() => { + prisma = { + organization: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + }, + organizationMember: { create: jest.fn(), findUnique: jest.fn() }, + user: { updateMany: jest.fn() }, + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(prisma)), + }; + service = new OrganizationsService(prisma as unknown as PrismaService); + }); + + describe('create', () => { + it('rejects a slug that is already taken', async () => { + prisma.organization.findUnique.mockResolvedValue({ id: 'existing-org' }); + + await expect( + service.create('user-1', { + name: 'Test Org', + slug: 'test-org', + industry: 'CONCERTS', + stellarAccount: 'G'.repeat(56), + } as never), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.organization.create).not.toHaveBeenCalled(); + }); + + it('creates the org, an OWNER membership, and promotes an ATTENDEE to ORGANIZER', async () => { + prisma.organization.findUnique.mockResolvedValue(null); + prisma.organization.create.mockResolvedValue({ + id: 'org-1', + slug: 'test-org', + }); + + const org = await service.create('user-1', { + name: 'Test Org', + slug: 'test-org', + industry: 'CONCERTS', + stellarAccount: 'G'.repeat(56), + } as never); + + expect(prisma.organizationMember.create).toHaveBeenCalledWith({ + data: { organizationId: 'org-1', userId: 'user-1', role: 'OWNER' }, + }); + expect(prisma.user.updateMany).toHaveBeenCalledWith({ + where: { id: 'user-1', role: 'ATTENDEE' }, + data: { role: 'ORGANIZER' }, + }); + expect(org).toEqual({ id: 'org-1', slug: 'test-org' }); + }); + }); + + describe('findOne', () => { + it('throws NotFoundException for a missing organization', async () => { + prisma.organization.findUnique.mockResolvedValue(null); + + await expect(service.findOne('missing-id')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + }); + + describe('assertMember', () => { + it('throws ForbiddenException when the user is not a member', async () => { + prisma.organizationMember.findUnique.mockResolvedValue(null); + + await expect( + service.assertMember('org-1', 'user-1'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('resolves silently when the user is a member', async () => { + prisma.organizationMember.findUnique.mockResolvedValue({ + id: 'membership-1', + }); + + await expect( + service.assertMember('org-1', 'user-1'), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/organizations/organizations.service.ts b/src/organizations/organizations.service.ts new file mode 100644 index 0000000..8396435 --- /dev/null +++ b/src/organizations/organizations.service.ts @@ -0,0 +1,60 @@ +import { + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { OrgMemberRole, UserRole } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateOrganizationDto } from './dto/create-organization.dto'; + +@Injectable() +export class OrganizationsService { + constructor(private readonly prisma: PrismaService) {} + + async create(userId: string, dto: CreateOrganizationDto) { + const existingSlug = await this.prisma.organization.findUnique({ + where: { slug: dto.slug }, + }); + if (existingSlug) { + throw new ConflictException('That organization slug is already taken'); + } + + return this.prisma.$transaction(async (tx) => { + const org = await tx.organization.create({ data: dto }); + await tx.organizationMember.create({ + data: { organizationId: org.id, userId, role: OrgMemberRole.OWNER }, + }); + await tx.user.updateMany({ + where: { id: userId, role: UserRole.ATTENDEE }, + data: { role: UserRole.ORGANIZER }, + }); + return org; + }); + } + + async findMine(userId: string) { + return this.prisma.organization.findMany({ + where: { members: { some: { userId } } }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findOne(id: string) { + const org = await this.prisma.organization.findUnique({ where: { id } }); + if (!org) { + throw new NotFoundException('Organization not found'); + } + return org; + } + + /** Throws unless `userId` is a member of `organizationId`. Used by Events/Tickets services to authorize writes. */ + async assertMember(organizationId: string, userId: string): Promise { + const membership = await this.prisma.organizationMember.findUnique({ + where: { organizationId_userId: { organizationId, userId } }, + }); + if (!membership) { + throw new ForbiddenException('You are not a member of this organization'); + } + } +} diff --git a/src/prisma/prisma.module.ts b/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts new file mode 100644 index 0000000..ba00c9f --- /dev/null +++ b/src/prisma/prisma.service.ts @@ -0,0 +1,16 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/src/repositories/deliveryRepository.js b/src/repositories/deliveryRepository.js deleted file mode 100644 index 77c79f8..0000000 --- a/src/repositories/deliveryRepository.js +++ /dev/null @@ -1,138 +0,0 @@ -'use strict'; - -/** - * Webhook delivery log repository. - * - * Schema mirrors the future PostgreSQL `webhook_deliveries` table: - * - * webhook_deliveries ( - * id text primary key, - * webhook_id text not null references webhooks(id) on delete cascade, - * event_id text not null, - * event_type text not null, - * status text not null, -- pending | success | failed - * attempts int not null default 0, - * last_error text, - * last_attempt_at timestamptz, - * next_retry_at timestamptz, - * response_status int, - * created_at timestamptz not null default now() - * ) - * - * Indexes that would back the queries below: - * (webhook_id, created_at desc) - listing recent deliveries per webhook - * (next_retry_at) - retry worker scan - * - * Atomicity: `popDueRetries` claims due retries from the `webhooks:retries` - * sorted set via a single Lua script (ZRANGEBYSCORE + ZREM in one round - * trip), registered on the ioredis client with `defineCommand`. Redis - * executes Lua scripts single-threaded to completion, so N instances of - * this backend calling `popDueRetries` concurrently against the same Redis - * always receive a disjoint set of ids - no delivery is ever claimed by - * more than one instance. This makes `webhookRetryWorker` safe to run on - * multiple replicas without duplicate delivery attempts. - */ - -const crypto = require('crypto'); -const cache = require('../services/cache'); - -const RETRY_QUEUE_KEY = 'webhooks:retries'; -const RECENT_DELIVERIES_LIMIT = 100; - -// Atomically claims up to ARGV[2] due members (score <= ARGV[1]) from the -// sorted set at KEYS[1] and removes them in the same round trip, so -// concurrent callers can never be handed overlapping ids. -const POP_DUE_RETRIES_LUA = ` -local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2]) -if #ids > 0 then - redis.call('ZREM', KEYS[1], unpack(ids)) -end -return ids -`; - -function ensurePopDueRetriesCommand(redis) { - if (typeof redis.popDueRetriesAtomic !== 'function') { - redis.defineCommand('popDueRetriesAtomic', { numberOfKeys: 1, lua: POP_DUE_RETRIES_LUA }); - } -} - -function key(id) { - return `webhook_delivery:${id}`; -} - -function indexKey(webhookId) { - return `webhook:${webhookId}:deliveries`; -} - -function generateId() { - return `dlv_${crypto.randomUUID().replace(/-/g, '').slice(0, 20)}`; -} - -async function create({ webhook_id, event_id, event_type }) { - const id = generateId(); - const now = new Date().toISOString(); - const record = { - id, - webhook_id, - event_id, - event_type, - status: 'pending', - attempts: 0, - last_error: null, - last_attempt_at: null, - next_retry_at: null, - response_status: null, - created_at: now, - }; - - const redis = cache.getClient(); - await cache.set(key(id), record); - await redis.zadd(indexKey(webhook_id), Date.now(), id); - await redis.zremrangebyrank(indexKey(webhook_id), 0, -(RECENT_DELIVERIES_LIMIT + 1)); - return record; -} - -async function findById(id) { - return cache.get(key(id)); -} - -async function update(id, patch) { - const existing = await cache.get(key(id)); - if (!existing) return null; - const next = { ...existing, ...patch, id: existing.id }; - await cache.set(key(id), next); - return next; -} - -async function listByWebhook(webhookId, limit = 50) { - const redis = cache.getClient(); - const ids = await redis.zrevrange(indexKey(webhookId), 0, Math.max(0, limit - 1)); - const records = await Promise.all(ids.map((id) => cache.get(key(id)))); - return records.filter(Boolean); -} - -async function scheduleRetry(deliveryId, nextRetryAtMs) { - const redis = cache.getClient(); - await redis.zadd(RETRY_QUEUE_KEY, nextRetryAtMs, deliveryId); -} - -async function popDueRetries(nowMs, max = 25) { - const redis = cache.getClient(); - ensurePopDueRetriesCommand(redis); - return redis.popDueRetriesAtomic(RETRY_QUEUE_KEY, nowMs, max); -} - -async function cancelRetry(deliveryId) { - const redis = cache.getClient(); - await redis.zrem(RETRY_QUEUE_KEY, deliveryId); -} - -module.exports = { - create, - findById, - update, - listByWebhook, - scheduleRetry, - popDueRetries, - cancelRetry, -}; diff --git a/src/repositories/webhookRepository.js b/src/repositories/webhookRepository.js deleted file mode 100644 index bc8462e..0000000 --- a/src/repositories/webhookRepository.js +++ /dev/null @@ -1,134 +0,0 @@ -'use strict'; - -/** - * Webhook repository. - * - * Schema mirrors the future PostgreSQL `webhooks` table so that swapping the - * Redis backing for a real DB only requires re-implementing this module: - * - * webhooks ( - * id text primary key, - * url text not null, - * events text[] not null, - * secret text not null, - * active boolean not null default true, - * description text, - * created_at timestamptz not null default now(), - * updated_at timestamptz not null default now() - * ) - */ - -const crypto = require('crypto'); -const cache = require('../services/cache'); - -const IDS_KEY = 'webhooks:ids'; - -function key(id) { - return `webhook:${id}`; -} - -function generateId() { - return `wh_${crypto.randomUUID().replace(/-/g, '').slice(0, 20)}`; -} - -function normalize(record) { - if (!record) return null; - return { - id: record.id, - url: record.url, - events: Array.isArray(record.events) ? [...record.events] : [], - secret: record.secret, - active: record.active !== false, - description: record.description || null, - created_at: record.created_at, - updated_at: record.updated_at, - }; -} - -async function create({ url, events, secret, description }) { - const id = generateId(); - const now = new Date().toISOString(); - const record = { - id, - url, - events, - secret, - active: true, - description: description || null, - created_at: now, - updated_at: now, - }; - const redis = cache.getClient(); - await cache.set(key(id), record); - // A sorted set scored by creation time, not a plain set — mirrors - // airdropsService/alerts.js's own IDS_KEY pattern, so paginating (added - // below) walks a deterministic, newest-first order rather than - // whatever arbitrary order SMEMBERS happened to return (#131). - await redis.zadd(IDS_KEY, Date.parse(now), id); - return normalize(record); -} - -async function findById(id) { - const record = await cache.get(key(id)); - return normalize(record); -} - -/** Every webhook, unpaginated — for internal fan-out (listActiveForEvent - * below), which needs the complete set to notify every subscriber, not a - * page of it. Not exposed as a public list endpoint; see list() for that. */ -async function listAll() { - const redis = cache.getClient(); - const ids = await redis.zrevrange(IDS_KEY, 0, -1); - const records = await Promise.all(ids.map((id) => cache.get(key(id)))); - return records.filter(Boolean).map(normalize); -} - -/** Returns { webhooks, total } — see routes/webhooks.js for how this is - * wrapped in the canonical pagination envelope. */ -async function list(page = 1, limit = 20) { - const redis = cache.getClient(); - const total = await redis.zcard(IDS_KEY); - const start = (page - 1) * limit; - const end = start + limit - 1; - const ids = await redis.zrevrange(IDS_KEY, start, end); - const records = await Promise.all(ids.map((id) => cache.get(key(id)))); - return { webhooks: records.filter(Boolean).map(normalize), total }; -} - -async function listActiveForEvent(eventType, matcher) { - const all = await listAll(); - return all.filter((w) => w.active && matcher(w.events, eventType)); -} - -async function update(id, patch) { - const existing = await cache.get(key(id)); - if (!existing) return null; - const next = { - ...existing, - ...patch, - id: existing.id, - created_at: existing.created_at, - updated_at: new Date().toISOString(), - }; - await cache.set(key(id), next); - return normalize(next); -} - -async function remove(id) { - const redis = cache.getClient(); - const existing = await cache.get(key(id)); - if (!existing) return null; - await cache.del(key(id)); - await redis.zrem(IDS_KEY, id); - return normalize(existing); -} - -module.exports = { - create, - findById, - list, - listAll, - listActiveForEvent, - update, - remove, -}; diff --git a/src/routes/airdrops.js b/src/routes/airdrops.js deleted file mode 100644 index 0ef55fd..0000000 --- a/src/routes/airdrops.js +++ /dev/null @@ -1,252 +0,0 @@ -const express = require('express'); -const multer = require('multer'); -const csv = require('csv-parser'); -const { Readable } = require('stream'); -const { pipeline } = require('stream/promises'); -const config = require('../config'); -const airdropsService = require('../services/airdrops'); -const logger = require('../logger'); -const AppError = require('../errors/AppError'); -const { flattenZodIssues, validate } = require('../middleware/validate'); -const { - airdropCreateBodySchema, - airdropRecipientsBodySchema, - airdropUpdateBodySchema, - paginationQuerySchema, - recipientsSchema, - routeIdParamsSchema, -} = require('../validation/schemas'); -const buildRateLimit = require('../middleware/rateLimit'); -const { StrKey } = require('stellar-sdk'); -const { paginateResponse } = require('../utils/paginate'); - -const router = express.Router(); -const CSV_PARSE_CHUNK_BYTES = 64 * 1024; -const upload = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: config.airdrops.csvMaxBytes }, -}); -const validateRouteIdParams = validate(routeIdParamsSchema, 'params'); -const validatePaginationQuery = validate(paginationQuerySchema, 'query'); -const validateRecipientBody = validate(airdropRecipientsBodySchema); - -function validateWithCurrentLedger(schemaFactory) { - return async (req, res, next) => { - try { - const currentLedger = await airdropsService.getCurrentLedger(); - return validate(schemaFactory(currentLedger))(req, res, next); - } catch (err) { - logger.error('Airdrop validation error', { error: err.message }); - return next(err); - } - }; -} - -const createAirdropLimit = buildRateLimit({ - windowSeconds: config.airdrops.rateLimit.windowSeconds, - max: config.airdrops.rateLimit.max, - keyPrefix: 'airdrops_create', -}); - -const addRecipientsLimit = buildRateLimit({ - windowSeconds: config.airdrops.rateLimit.windowSeconds, - max: config.airdrops.rateLimit.max, - keyPrefix: 'airdrops_recipients', -}); - -function uploadRecipientsFile(req, res, next) { - upload.single('file')(req, res, (err) => { - if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') { - return next(new AppError( - 'PAYLOAD_TOO_LARGE', - `CSV file cannot exceed ${config.airdrops.csvMaxBytes} bytes`, - 413, - { max_bytes: config.airdrops.csvMaxBytes } - )); - } - return next(err); - }); -} - -function isValidStellarAddress(address) { - try { - return StrKey.isValidEd25519PublicKey(address); - } catch { - return false; - } -} - -function parseRecipients(recipients, next) { - const result = recipientsSchema.safeParse(recipients); - if (!result.success) { - return next(new AppError('VALIDATION_ERROR', 'Validation failed', 400, { - fields: flattenZodIssues(result.error), - })); - } - return result.data; -} - -async function parseCSV(buffer) { - const results = []; - let rowCount = 0; - const chunks = (function* chunkBuffer() { - for (let offset = 0; offset < buffer.length; offset += CSV_PARSE_CHUNK_BYTES) { - yield buffer.subarray(offset, offset + CSV_PARSE_CHUNK_BYTES); - } - }()); - - await pipeline(Readable.from(chunks), csv(), async (rows) => { - for await (const data of rows) { - rowCount += 1; - if (rowCount > config.airdrops.maxRecipients) { - throw new AppError('VALIDATION_ERROR', 'recipients cannot exceed 10,000', 400); - } - - const address = data.address || data.Address || data.ADDRESS; - const amount = parseFloat(data.amount || data.Amount || data.AMOUNT); - if (address && !Number.isNaN(amount)) { - results.push({ address, amount }); - } - } - }); - - return results; -} - -router.post('/airdrops', createAirdropLimit, validateWithCurrentLedger(airdropCreateBodySchema), async (req, res, next) => { - try { - const airdrop = await airdropsService.create(req.validated.body); - return res.status(201).json(airdrop); - } catch (err) { - logger.error('Create airdrop error', { error: err.message }); - return next(err); - } -}); - -router.get('/airdrops', validatePaginationQuery, async (req, res, next) => { - try { - const { page, limit } = req.validated.query; - const result = await airdropsService.list(page, limit); - return res.json(paginateResponse(result.airdrops, result.total, { page, limit })); - } catch (err) { - logger.error('List airdrops error', { error: err.message }); - return next(err); - } -}); - -router.get('/airdrops/:id', validateRouteIdParams, async (req, res, next) => { - try { - const airdrop = await airdropsService.get(req.params.id); - if (!airdrop) { - return next(new AppError('NOT_FOUND', 'Airdrop not found', 404)); - } - return res.json(airdrop); - } catch (err) { - logger.error('Get airdrop error', { error: err.message }); - return next(err); - } -}); - -router.patch('/airdrops/:id', validateRouteIdParams, validateWithCurrentLedger(airdropUpdateBodySchema), async (req, res, next) => { - try { - const airdrop = await airdropsService.update(req.params.id, req.validated.body); - if (!airdrop) { - return next(new AppError('NOT_FOUND', 'Airdrop not found', 404)); - } - return res.json(airdrop); - } catch (err) { - logger.error('Update airdrop error', { error: err.message }); - return next(err); - } -}); - -router.delete('/airdrops/:id', validateRouteIdParams, async (req, res, next) => { - try { - const deleted = await airdropsService.remove(req.params.id); - if (!deleted) { - return next(new AppError('NOT_FOUND', 'Airdrop not found', 404)); - } - return res.json({ deleted: true, id: req.params.id }); - } catch (err) { - logger.error('Delete airdrop error', { error: err.message }); - return next(err); - } -}); - -router.post('/airdrops/:id/cancel', validateRouteIdParams, async (req, res, next) => { - try { - const airdrop = await airdropsService.cancel(req.params.id); - if (!airdrop) { - return next(new AppError('NOT_FOUND', 'Airdrop not found', 404)); - } - return res.json(airdrop); - } catch (err) { - logger.error('Cancel airdrop error', { error: err.message }); - return next(err); - } -}); - -router.post('/airdrops/:id/recipients', validateRouteIdParams, addRecipientsLimit, uploadRecipientsFile, validateRecipientBody, async (req, res, next) => { - try { - const airdrop = await airdropsService.get(req.params.id); - if (!airdrop) { - return next(new AppError('NOT_FOUND', 'Airdrop not found', 404)); - } - - let recipients = []; - if (req.file) { - recipients = await parseCSV(req.file.buffer); - recipients = parseRecipients(recipients, next); - if (!recipients) return undefined; - } else if (req.validated.body.recipients) { - recipients = req.validated.body.recipients; - } else { - return next(new AppError('VALIDATION_ERROR', 'recipients or file is required', 400)); - } - - if (recipients.length > config.airdrops.maxRecipients) { - return next(new AppError('VALIDATION_ERROR', 'recipients cannot exceed 10,000', 400)); - } - - const recipientSet = new Set(); - let sum = 0; - for (let i = 0; i < recipients.length; i++) { - const r = recipients[i]; - if (!r.address || !isValidStellarAddress(r.address)) { - return next(new AppError('VALIDATION_ERROR', `recipient ${i}: invalid Stellar address`, 400)); - } - if (recipientSet.has(r.address)) { - return next(new AppError('VALIDATION_ERROR', `recipient ${i}: duplicate address ${r.address}`, 400)); - } - recipientSet.add(r.address); - if (typeof r.amount !== 'number' || r.amount <= 0) { - return next(new AppError('VALIDATION_ERROR', `recipient ${i}: amount must be a positive number`, 400)); - } - sum += r.amount; - } - - await airdropsService.addRecipients(req.params.id, recipients); - return res.status(201).json({ added: recipients.length }); - } catch (err) { - logger.error('Add recipients error', { error: err.message }); - return next(err); - } -}); - -router.get('/airdrops/:id/recipients', validateRouteIdParams, validatePaginationQuery, async (req, res, next) => { - try { - const airdrop = await airdropsService.get(req.params.id); - if (!airdrop) { - return next(new AppError('NOT_FOUND', 'Airdrop not found', 404)); - } - - const { page, limit } = req.validated.query; - const result = await airdropsService.listRecipients(req.params.id, page, limit); - return res.json(paginateResponse(result.recipients, result.total, { page, limit })); - } catch (err) { - logger.error('List recipients error', { error: err.message }); - return next(err); - } -}); - -module.exports = router; diff --git a/src/routes/alerts.js b/src/routes/alerts.js deleted file mode 100644 index 8f483d8..0000000 --- a/src/routes/alerts.js +++ /dev/null @@ -1,52 +0,0 @@ -const express = require('express'); -const { validate } = require('../middleware/validate'); -const alertsService = require('../services/alerts'); -const logger = require('../logger'); -const AppError = require('../errors/AppError'); -const { alertCreateBodySchema, paginationQuerySchema, routeIdParamsSchema } = require('../validation/schemas'); - -const router = express.Router(); -const validateRouteIdParams = validate(routeIdParamsSchema, 'params'); - -const { parsePagination, paginateResponse } = require('../utils/paginate'); - -router.post('/alerts', validate(alertCreateBodySchema), async (req, res, next) => { - try { - const alert = await alertsService.create(req.validated.body); - return res.status(201).json(alert); - } catch (err) { - logger.error('Create alert error', { error: err.message }); - return next(err); - } -}); - -router.get('/alerts', validate(paginationQuerySchema, 'query'), async (req, res, next) => { - try { - const pagination = parsePagination(req.query); - const result = await alertsService.listPaginated(pagination); - return res.json( - paginateResponse( - result.alerts, - result.total, - pagination - )); - } catch (err) { - logger.error('List alerts error', { error: err.message }); - return next(err); - } -}); - -router.delete('/alerts/:id', validateRouteIdParams, async (req, res, next) => { - try { - const deleted = await alertsService.remove(req.params.id); - if (!deleted) { - return next(new AppError('NOT_FOUND', 'Alert not found', 404)); - } - return res.json({ deleted: true, id: req.params.id }); - } catch (err) { - logger.error('Delete alert error', { error: err.message }); - return next(err); - } -}); - -module.exports = router; diff --git a/src/routes/apiDocs.js b/src/routes/apiDocs.js deleted file mode 100644 index d660052..0000000 --- a/src/routes/apiDocs.js +++ /dev/null @@ -1,26 +0,0 @@ -const express = require('express'); -const path = require('path'); -const YAML = require('yamljs'); -const swaggerUi = require('swagger-ui-express'); -const config = require('../config'); - -const router = express.Router(); - -const openApiDocument = YAML.load(path.join(__dirname, '../../openapi.yaml')); - -router.get('/openapi.yaml', (_req, res) => { - res.type('yaml').sendFile(path.join(__dirname, '../../openapi.yaml')); -}); - -if (config.nodeEnv === 'development') { - router.use('/', swaggerUi.serve, swaggerUi.setup(openApiDocument, { - explorer: true, - customSiteTitle: 'SmartDrop API Docs', - })); -} else { - router.get('/', (_req, res) => { - res.redirect('/api-docs/openapi.yaml'); - }); -} - -module.exports = router; diff --git a/src/routes/indexer.js b/src/routes/indexer.js deleted file mode 100644 index f756f56..0000000 --- a/src/routes/indexer.js +++ /dev/null @@ -1,104 +0,0 @@ -const express = require('express'); -const eventStore = require('../indexer/eventStore'); -const indexerPoller = require('../indexer/runtime'); -const logger = require('../logger'); -const { parsePagination, paginateResponse } = require('../utils/paginate'); - -const router = express.Router(); - -function isValidId(value) { - return typeof value === 'string' && /^[A-Za-z0-9:_-]{1,128}$/.test(value); -} - -function isValidAddress(value) { - return typeof value === 'string' && /^[A-Z0-9]{10,80}$/.test(value); -} - -router.get('/airdrops/:id/status', async (req, res) => { - try { - if (!isValidId(req.params.id)) { - return res.status(400).json({ error: 'Invalid airdrop id' }); - } - - const status = await eventStore.getAirdropStatus(req.params.id); - if (!status) { - return res.status(404).json({ error: 'Airdrop not indexed' }); - } - - return res.json(status); - } catch (err) { - logger.error('Airdrop status lookup failed', { error: err.message }); - return res.status(500).json({ error: 'Internal server error' }); - } -}); - -// Named distinctly from airdrops.js's own `/airdrops/:id/recipients` (the -// stored/intended recipient list): this returns recipients derived from -// indexed on-chain claim events, a different source of truth. The two -// routers previously registered the exact same path, and since this -// router is mounted first in src/index.js, it silently shadowed the real -// listRecipients handler in airdrops.js on every request. -// getAirdropRecipients/getRecipientClaims below fully materialize their -// list from a single Redis key regardless (see eventStore.js's -// getJsonList) — there's no server-side "fetch only a page" available at -// the storage layer, so pagination here is a slice of the already-fetched -// array plus the canonical envelope, not a more efficient query (#131). -router.get('/airdrops/:id/onchain-recipients', async (req, res) => { - try { - if (!isValidId(req.params.id)) { - return res.status(400).json({ error: 'Invalid airdrop id' }); - } - - const allRecipients = await eventStore.getAirdropRecipients(req.params.id); - const { page, limit } = parsePagination(req.query); - const start = (page - 1) * limit; - const pageRecipients = allRecipients.slice(start, start + limit); - return res.json(paginateResponse(pageRecipients, allRecipients.length, { page, limit })); - } catch (err) { - logger.error('Airdrop recipients lookup failed', { error: err.message }); - return res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.get('/recipients/:address/claims', async (req, res) => { - try { - if (!isValidAddress(req.params.address)) { - return res.status(400).json({ error: 'Invalid recipient address' }); - } - - const allClaims = await eventStore.getRecipientClaims(req.params.address); - const { page, limit } = parsePagination(req.query); - const start = (page - 1) * limit; - const pageClaims = allClaims.slice(start, start + limit); - return res.json(paginateResponse(pageClaims, allClaims.length, { page, limit })); - } catch (err) { - logger.error('Recipient claims lookup failed', { error: err.message }); - return res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.get('/indexer/status', async (_req, res) => { - try { - const stats = await eventStore.getStats(); - const poller = indexerPoller.getStatus(); - const hasLatestLedger = poller.latest_ledger !== null && poller.latest_ledger !== undefined; - const latestLedger = Number(poller.latest_ledger); - const lastLedger = Number(stats.last_ledger); - const ledgerLag = hasLatestLedger && Number.isFinite(latestLedger) && Number.isFinite(lastLedger) - ? Math.max(0, latestLedger - lastLedger) - : null; - - return res.json({ - ...poller, - last_ledger: stats.last_ledger, - events_count: stats.events_count, - lag: ledgerLag, - ledger_lag: ledgerLag, - }); - } catch (err) { - logger.error('Indexer status lookup failed', { error: err.message }); - return res.status(500).json({ error: 'Internal server error' }); - } -}); - -module.exports = router; diff --git a/src/routes/keys.js b/src/routes/keys.js deleted file mode 100644 index bdfedfb..0000000 --- a/src/routes/keys.js +++ /dev/null @@ -1,52 +0,0 @@ -const express = require('express'); -const { requireApiKey } = require('../middleware/auth'); -const { validate } = require('../middleware/validate'); -const apiKeys = require('../services/apiKeys'); -const logger = require('../logger'); -const AppError = require('../errors/AppError'); -const { keyCreateBodySchema, routeIdParamsSchema } = require('../validation/schemas'); - -const router = express.Router(); -const validateRouteIdParams = validate(routeIdParamsSchema, 'params'); - -router.use('/keys', requireApiKey({ scopes: ['admin'] })); - -router.get('/keys', async (_req, res, next) => { - try { - const keys = await apiKeys.listKeys(); - return res.json({ keys }); - } catch (err) { - logger.error('List API keys error', { error: err.message }); - return next(err); - } -}); - -router.post('/keys', validate(keyCreateBodySchema), async (req, res, next) => { - try { - const { label, scopes } = req.validated.body; - - const created = await apiKeys.createKey({ - label, - scopes: scopes || ['default'], - }); - return res.status(201).json(created); - } catch (err) { - logger.error('Create API key error', { error: err.message }); - return next(err); - } -}); - -router.delete('/keys/:id', validateRouteIdParams, async (req, res, next) => { - try { - const deleted = await apiKeys.revokeKey(req.params.id); - if (!deleted) { - return next(new AppError('NOT_FOUND', 'API key not found', 404)); - } - return res.json({ deleted: true, key: deleted }); - } catch (err) { - logger.error('Revoke API key error', { error: err.message }); - return next(err); - } -}); - -module.exports = router; diff --git a/src/routes/prices.js b/src/routes/prices.js deleted file mode 100644 index 7914843..0000000 --- a/src/routes/prices.js +++ /dev/null @@ -1,60 +0,0 @@ -const express = require('express'); -const config = require('../config'); -const { requireApiKey } = require('../middleware/auth'); -const { validate } = require('../middleware/validate'); -const buildRateLimit = require('../middleware/rateLimit'); -const priceOracle = require('../services/priceOracle'); -const AppError = require('../errors/AppError'); -const { priceParamsSchema, priceQuerySchema } = require('../validation/schemas'); - -const router = express.Router(); - -const validatePriceParams = validate(priceParamsSchema, 'params'); -const validatePriceQuery = validate(priceQuerySchema, 'query'); -const priceLimit = buildRateLimit({ - windowSeconds: config.priceRateLimit.windowSeconds, - max: config.priceRateLimit.max, - keyPrefix: 'prices', -}); - -router.use(priceLimit); - -function validateAssetCode(assetCode) { - if (!assetCode || typeof assetCode !== 'string') return false; - if (assetCode.length < 1 || assetCode.length > 12) return false; - return /^[A-Z0-9]+$/.test(assetCode); -} - -router.get('/prices/:asset_code', validatePriceParams, validatePriceQuery, async (req, res, next) => { - try { - const { asset_code: normalizedCode } = req.validated.params; - const { issuer } = req.query; - - const priceData = await priceOracle.getPrice(normalizedCode, issuer || null); - - if (priceData.price_usd === null) { - throw new AppError('NOT_FOUND', `No price data found for ${normalizedCode}`, 404, { asset_code: normalizedCode, issuer: issuer || null }); - } - - return res.json(priceData); - } catch (err) { - return next(err); - } -}); - -router.get('/prices/:asset_code/refresh', requireApiKey(), validatePriceParams, validatePriceQuery, async (req, res, next) => { - try { - const { asset_code: normalizedCode } = req.validated.params; - const { issuer } = req.query; - - const priceData = await priceOracle.fetchFreshPrice(normalizedCode, issuer || null); - if (priceData.price_usd === null) { - throw new AppError('UPSTREAM_ERROR', 'All price sources failed', 502, { asset_code: normalizedCode, issuer: issuer || null }); - } - return res.json(priceData); - } catch (err) { - return next(err); - } -}); - -module.exports = router; diff --git a/src/routes/webhooks.js b/src/routes/webhooks.js deleted file mode 100644 index 16599c1..0000000 --- a/src/routes/webhooks.js +++ /dev/null @@ -1,145 +0,0 @@ -'use strict'; - -const express = require('express'); -const config = require('../config'); -const { validate } = require('../middleware/validate'); -const webhookRepo = require('../repositories/webhookRepository'); -const deliveryRepo = require('../repositories/deliveryRepository'); -const dispatcher = require('../services/webhookDispatcher'); -const signatureService = require('../services/webhookSignature'); -const buildRateLimit = require('../middleware/rateLimit'); -const AppError = require('../errors/AppError'); -const { paginateResponse } = require('../utils/paginate'); -const { - paginationQuerySchema, - routeIdParamsSchema, - webhookCreateBodySchema, - webhookDeliveriesQuerySchema, - webhookPatchBodySchema, -} = require('../validation/schemas'); - -const router = express.Router(); -const validateRouteIdParams = validate(routeIdParamsSchema, 'params'); -const validatePaginationQuery = validate(paginationQuerySchema, 'query'); - -const manageLimit = buildRateLimit({ - windowSeconds: config.webhooks.rateLimit.windowSeconds, - max: config.webhooks.rateLimit.max, - keyPrefix: 'webhooks', -}); - -const testLimit = buildRateLimit({ - windowSeconds: config.webhooks.testRateLimit.windowSeconds, - max: config.webhooks.testRateLimit.max, - keyPrefix: 'webhooks_test', -}); - -router.use('/webhooks', manageLimit); - -function publicView(webhook) { - if (!webhook) return null; - return { - id: webhook.id, - url: webhook.url, - events: webhook.events, - active: webhook.active, - description: webhook.description, - created_at: webhook.created_at, - updated_at: webhook.updated_at, - secret_preview: webhook.secret ? `${webhook.secret.slice(0, 10)}…` : null, - }; -} - -router.post('/webhooks', validate(webhookCreateBodySchema), async (req, res, next) => { - try { - const body = req.validated.body; - const secret = body.secret || signatureService.generateSecret(); - const webhook = await webhookRepo.create({ - url: body.url, - events: body.events, - secret, - description: body.description, - }); - - return res.status(201).json({ - ...publicView(webhook), - secret, - secret_warning: 'Store this secret now — it will not be shown again in plaintext.', - }); - } catch (err) { - return next(err); - } -}); - -router.get('/webhooks', validatePaginationQuery, async (req, res, next) => { - try { - const { page, limit } = req.validated.query; - const result = await webhookRepo.list(page, limit); - return res.json( - paginateResponse(result.webhooks.map(publicView), result.total, { page, limit }), - ); - } catch (err) { - return next(err); - } -}); - -router.get('/webhooks/:id', validateRouteIdParams, async (req, res, next) => { - try { - const webhook = await webhookRepo.findById(req.params.id); - if (!webhook) return next(new AppError('NOT_FOUND', 'Webhook not found', 404)); - return res.json(publicView(webhook)); - } catch (err) { - return next(err); - } -}); - -router.patch('/webhooks/:id', validateRouteIdParams, validate(webhookPatchBodySchema), async (req, res, next) => { - try { - const patch = req.validated.body; - const updated = await webhookRepo.update(req.params.id, patch); - if (!updated) return next(new AppError('NOT_FOUND', 'Webhook not found', 404)); - return res.json(publicView(updated)); - } catch (err) { - return next(err); - } -}); - -router.delete('/webhooks/:id', validateRouteIdParams, async (req, res, next) => { - try { - const deleted = await webhookRepo.remove(req.params.id); - if (!deleted) return next(new AppError('NOT_FOUND', 'Webhook not found', 404)); - return res.json({ deleted: true, id: req.params.id }); - } catch (err) { - return next(err); - } -}); - -router.post('/webhooks/:id/test', validateRouteIdParams, testLimit, async (req, res, next) => { - try { - const delivery = await dispatcher.sendTest(req.params.id); - if (!delivery) return next(new AppError('NOT_FOUND', 'Webhook not found', 404)); - return res.status(202).json({ - delivery_id: delivery.id, - status: delivery.status, - attempts: delivery.attempts, - response_status: delivery.response_status, - last_error: delivery.last_error, - }); - } catch (err) { - return next(err); - } -}); - -router.get('/webhooks/:id/deliveries', validateRouteIdParams, validate(webhookDeliveriesQuerySchema, 'query'), async (req, res, next) => { - try { - const webhook = await webhookRepo.findById(req.params.id); - if (!webhook) return next(new AppError('NOT_FOUND', 'Webhook not found', 404)); - const { limit } = req.validated.query; - const deliveries = await deliveryRepo.listByWebhook(req.params.id, limit); - return res.json({ deliveries }); - } catch (err) { - return next(err); - } -}); - -module.exports = router; diff --git a/src/schemas/pagination.js b/src/schemas/pagination.js deleted file mode 100644 index 656c023..0000000 --- a/src/schemas/pagination.js +++ /dev/null @@ -1,20 +0,0 @@ -const { z } = require('zod'); - -const paginationSchema = z.object({ - page: z.number(), - limit: z.number(), - total: z.number(), - total_pages: z.number(), - has_next: z.boolean(), - has_prev: z.boolean(), -}); - -const paginatedResponseSchema = z.object({ - data: z.array(z.any()), - pagination: paginationSchema, -}); - -module.exports = { - paginationSchema, - paginatedResponseSchema, -}; \ No newline at end of file diff --git a/src/services/airdrops.js b/src/services/airdrops.js deleted file mode 100644 index cbbafe5..0000000 --- a/src/services/airdrops.js +++ /dev/null @@ -1,231 +0,0 @@ -const crypto = require('crypto'); -const cache = require('./cache'); -const logger = require('../logger'); -const { Horizon } = require('stellar-sdk'); -const config = require('../config'); - -const IDS_KEY = 'airdrops:ids'; - -function airdropKey(id) { - return `airdrop:${id}`; -} - -function recipientsKey(airdropId) { - return `airdrop:${airdropId}:recipients`; -} - -function generateId() { - return `drop_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`; -} - -const horizon = new Horizon.Server(config.stellar.horizonUrl); - -// getCurrentLedger() is a live Horizon call. Callers that need to check many -// airdrops in quick succession (the expiry reconciliation job, in -// particular — see #88) would otherwise issue one Horizon request per -// airdrop per cycle; cache the result briefly so bursts of calls within the -// same window reuse one ledger read instead of hammering Horizon, the same -// rate-limit concern already applied to CoinGecko/CoinMarketCap elsewhere. -let cachedLedger = null; -let cachedLedgerAt = 0; - -async function getCurrentLedger() { - const now = Date.now(); - if (cachedLedger !== null && now - cachedLedgerAt < config.airdrops.ledgerCacheTtlMs) { - return cachedLedger; - } - - const ledger = await horizon.ledgers().order('desc').limit(1).call(); - cachedLedger = ledger.records[0].sequence; - cachedLedgerAt = now; - return cachedLedger; -} - -async function create(data) { - const { name, description, asset, asset_issuer, total_amount, expiry_ledger, recipients = [] } = data; - const id = generateId(); - - const airdrop = { - id, - name, - description, - asset, - asset_issuer, - total_amount, - expiry_ledger, - status: 'draft', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; - - const redis = cache.getClient(); - await cache.set(airdropKey(id), airdrop); - await redis.zadd(IDS_KEY, Date.now(), id); - - if (recipients.length > 0) { - await redis.lpush(recipientsKey(id), ...recipients.map((r) => JSON.stringify(r))); - } - - return airdrop; -} - -/** - * Pages through the full airdrop ID sorted set via ZSCAN instead of ZREVRANGE. Used - * by the expiry reconciliation job (#88), which needs to visit every - * airdrop every cycle: ZREVRANGE with 0 -1 returns the whole set in one call - * and would need it all held in memory at once, which doesn't scale as the - * set grows. ZSCAN pages incrementally with a small, bounded cursor cost per - * call. `list()` above is unchanged — this is a separate, job-internal - * scanning path, not a replacement for the paginated HTTP listing endpoint. - */ -async function* scanIds(batchSize = config.airdrops.expiryScanBatchSize) { - const redis = cache.getClient(); - let cursor = '0'; - do { - const [nextCursor, batchWithScores] = await redis.zscan(IDS_KEY, cursor, 'COUNT', batchSize); - cursor = nextCursor; - const batch = batchWithScores.filter((_, index) => index % 2 === 0); - if (batch.length > 0) { - yield batch; - } - } while (cursor !== '0'); -} - -// Statuses an airdrop cannot leave once reached. -const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'expired']); - -/** - * Atomically transitions an airdrop to 'expired' if — and only if — it's - * still in a non-terminal status *and* its expiry_ledger has actually - * passed, checked and written in a single Lua script so two processes (or - * two overlapping job cycles) racing on the same airdrop can't both "win" - * and each fire a duplicate webhook. Returns the updated airdrop on a - * successful transition, or null if nothing changed (already terminal, not - * yet expired, or the airdrop doesn't exist) — callers use that to decide - * whether to dispatch a webhook. - */ -const MARK_EXPIRED_SCRIPT = ` -local raw = redis.call('GET', KEYS[1]) -if not raw then return false end -local airdrop = cjson.decode(raw) -local terminal = { completed = true, failed = true, cancelled = true, expired = true } -if terminal[airdrop.status] then return false end -if not airdrop.expiry_ledger or tonumber(airdrop.expiry_ledger) > tonumber(ARGV[1]) then - return false -end -airdrop.status = 'expired' -airdrop.updated_at = ARGV[2] -local updated = cjson.encode(airdrop) -redis.call('SET', KEYS[1], updated) -return updated -`; - -async function markExpired(id, currentLedger) { - const redis = cache.getClient(); - const result = await redis.eval( - MARK_EXPIRED_SCRIPT, - 1, - airdropKey(id), - currentLedger, - new Date().toISOString(), - ); - if (!result) return null; - return JSON.parse(result); -} - -// Returns { airdrops, total } rather than a full pagination envelope — the -// route layer wraps this in the canonical envelope via -// utils/paginate.js's paginateResponse, the same split routes/alerts.js -// already uses for its own list endpoint (#131). -async function list(page = 1, limit = 20) { - const redis = cache.getClient(); - const total = await redis.zcard(IDS_KEY); - const start = (page - 1) * limit; - const end = start + limit - 1; - const paginatedIds = await redis.zrevrange(IDS_KEY, start, end); - const airdrops = await Promise.all(paginatedIds.map((id) => cache.get(airdropKey(id)))); - - return { airdrops: airdrops.filter(Boolean), total }; -} - -async function get(id) { - return await cache.get(airdropKey(id)); -} - -async function update(id, data) { - const airdrop = await get(id); - if (!airdrop) return null; - - const { name, description, expiry_ledger } = data; - const updated = { - ...airdrop, - name: name !== undefined ? name : airdrop.name, - description: description !== undefined ? description : airdrop.description, - expiry_ledger: expiry_ledger !== undefined ? expiry_ledger : airdrop.expiry_ledger, - updated_at: new Date().toISOString(), - }; - - await cache.set(airdropKey(id), updated); - return updated; -} - -async function remove(id) { - const redis = cache.getClient(); - const existing = await get(id); - if (!existing) return null; - - await cache.del(airdropKey(id)); - await cache.del(recipientsKey(id)); - await redis.zrem(IDS_KEY, id); - return existing; -} - -async function cancel(id) { - const airdrop = await get(id); - if (!airdrop) return null; - - if (airdrop.status === 'cancelled') { - return airdrop; - } - - const updated = { - ...airdrop, - status: 'cancelled', - updated_at: new Date().toISOString(), - }; - - await cache.set(airdropKey(id), updated); - return updated; -} - -async function addRecipients(airdropId, recipients) { - const redis = cache.getClient(); - await redis.rpush(recipientsKey(airdropId), ...recipients.map((r) => JSON.stringify(r))); -} - -// Returns { recipients, total } — see list()'s comment above. -async function listRecipients(airdropId, page = 1, limit = 20) { - const redis = cache.getClient(); - const total = await redis.llen(recipientsKey(airdropId)); - const start = (page - 1) * limit; - const end = start + limit - 1; - const serializedRecipients = await redis.lrange(recipientsKey(airdropId), start, end); - const recipients = serializedRecipients.map((r) => JSON.parse(r)); - - return { recipients, total }; -} - -module.exports = { - create, - list, - get, - update, - remove, - cancel, - addRecipients, - listRecipients, - getCurrentLedger, - scanIds, - markExpired, - TERMINAL_STATUSES, -}; diff --git a/src/services/alerts.js b/src/services/alerts.js deleted file mode 100644 index 5ba9320..0000000 --- a/src/services/alerts.js +++ /dev/null @@ -1,140 +0,0 @@ -const crypto = require('crypto'); -const cache = require('./cache'); -const webhook = require('./webhook'); -const logger = require('../logger'); - -const IDS_KEY = 'alerts:ids'; -const COOLDOWN_MS = 5 * 60 * 1000; - -function alertKey(id) { - return `alert:${id}`; -} - -function generateId() { - return `alrt_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`; -} - -function isTriggered(alert, priceUsd) { - if (alert.type === 'above') return priceUsd > alert.threshold_usd; - if (alert.type === 'below') return priceUsd < alert.threshold_usd; - if (alert.type === 'change_pct') { - if (alert.baseline_price === null) return false; - const pct = Math.abs((priceUsd - alert.baseline_price) / alert.baseline_price) * 100; - return pct >= alert.threshold_usd; - } - return false; -} - -async function create(data) { - const { asset, type, threshold_usd, webhook_url, webhook_secret, repeat } = data; - - const id = generateId(); - - let baselinePrice = null; - if (type === 'change_pct') { - const cached = await cache.get(`price:${asset.toUpperCase()}`); - if (cached && cached.price) baselinePrice = cached.price; - } - - const alert = { - id, - asset: asset.toUpperCase(), - type, - threshold_usd, - webhook_url, - webhook_secret, - repeat: repeat === true, - created_at: new Date().toISOString(), - last_fired_at: null, - baseline_price: baselinePrice, - }; - - const redis = cache.getClient(); - await cache.set(alertKey(id), alert); - await redis.zadd(IDS_KEY, Date.now(), id); - - return alert; -} - -async function list() { - const redis = cache.getClient(); - const ids = await redis.zrevrange(IDS_KEY, 0, -1); - const alerts = await Promise.all(ids.map((id) => cache.get(alertKey(id)))); - return alerts.filter(Boolean); -} - -async function listPaginated({ offset = 0, limit = 20 } = {}) { - const redis = cache.getClient(); - const total = await redis.zcard(IDS_KEY); - const paginatedIds = await redis.zrevrange(IDS_KEY, offset, offset + limit - 1); - const alerts = await Promise.all( - paginatedIds.map((id) => cache.get(alertKey(id))) - ); - return { - alerts: alerts.filter(Boolean), - total - }; -} - -async function remove(id) { - const redis = cache.getClient(); - const existing = await cache.get(alertKey(id)); - if (!existing) return null; - await cache.del(alertKey(id)); - await redis.zrem(IDS_KEY, id); - return existing; -} - -async function fire(alert, priceUsd) { - const payload = { - event: 'price.alert', - alert_id: alert.id, - asset: alert.asset, - type: alert.type, - threshold_usd: alert.threshold_usd, - actual_price_usd: priceUsd, - triggered_at: new Date().toISOString(), - }; - - logger.info('Price alert triggered', { alert_id: alert.id, asset: alert.asset, price: priceUsd }); - await webhook.deliver(alert.webhook_url, alert.webhook_secret, payload); -} - -async function evaluateForAsset(asset, priceUsd) { - const redis = cache.getClient(); - const ids = await redis.zrevrange(IDS_KEY, 0, -1); - - for (const id of ids) { - const alert = await cache.get(alertKey(id)); - if (!alert || alert.asset !== asset.toUpperCase()) continue; - - if (!isTriggered(alert, priceUsd)) continue; - - if (alert.repeat && alert.last_fired_at) { - const elapsed = Date.now() - new Date(alert.last_fired_at).getTime(); - if (elapsed < COOLDOWN_MS) continue; - } - - await fire(alert, priceUsd); - - if (!alert.repeat) { - await remove(id); - } else { - alert.last_fired_at = new Date().toISOString(); - await cache.set(alertKey(id), alert); - } - } -} - -async function evaluateAll() { - const allAlerts = await list(); - const assets = [...new Set(allAlerts.map((a) => a.asset))]; - - for (const asset of assets) { - const cached = await cache.get(`price:${asset}`); - if (!cached || cached.price == null) continue; - await evaluateForAsset(asset, cached.price); - } -} - -module.exports = { create, list, listPaginated, remove, evaluateForAsset, evaluateAll }; diff --git a/src/services/apiKeys.js b/src/services/apiKeys.js deleted file mode 100644 index af6f295..0000000 --- a/src/services/apiKeys.js +++ /dev/null @@ -1,128 +0,0 @@ -const crypto = require('crypto'); -const cache = require('./cache'); -const config = require('../config'); - -const KEY_PREFIX = 'api_key:'; -const HASH_PREFIX = 'api_key_hash:'; -const IDS_KEY = 'api_keys'; - -function hashApiKey(apiKey) { - return crypto.createHash('sha256').update(apiKey).digest('hex'); -} - -function constantTimeSecretEqual(actual, expected) { - const actualDigest = crypto.createHash('sha256').update(actual).digest(); - const expectedDigest = crypto.createHash('sha256').update(expected).digest(); - return crypto.timingSafeEqual(actualDigest, expectedDigest); -} - -function sanitize(record) { - if (!record) return null; - const { key_hash, ...safe } = record; - return safe; -} - -function generateApiKey() { - return crypto.randomBytes(32).toString('hex'); -} - -function keyId() { - return `key_${crypto.randomUUID().replace(/-/g, '')}`; -} - -function keyPath(id) { - return `${KEY_PREFIX}${id}`; -} - -function hashPath(hash) { - return `${HASH_PREFIX}${hash}`; -} - -async function getKey(id) { - return cache.get(keyPath(id)); -} - -async function listKeys() { - const redis = cache.getClient(); - const ids = await redis.zrevrange(IDS_KEY, 0, -1); - const records = await Promise.all(ids.map((id) => getKey(id))); - return records.filter(Boolean).map(sanitize); -} - -async function createKey({ label, scopes = ['default'] }) { - const apiKey = generateApiKey(); - const hashed = hashApiKey(apiKey); - const now = new Date().toISOString(); - const record = { - id: keyId(), - label, - key_prefix: apiKey.slice(0, 8), - key_hash: hashed, - scopes, - created_at: now, - last_used_at: null, - }; - - const redis = cache.getClient(); - await cache.set(keyPath(record.id), record); - await cache.set(hashPath(hashed), record.id); - await redis.zadd(IDS_KEY, Date.now(), record.id); - - return { - api_key: apiKey, - key: sanitize(record), - }; -} - -async function revokeKey(id) { - const record = await getKey(id); - if (!record) return null; - - const redis = cache.getClient(); - await cache.del(keyPath(id)); - await cache.del(hashPath(record.key_hash)); - await redis.zrem(IDS_KEY, id); - return sanitize(record); -} - -async function touch(record) { - const updated = { - ...record, - last_used_at: new Date().toISOString(), - }; - await cache.set(keyPath(record.id), updated); - return sanitize(updated); -} - -async function validateApiKey(apiKey) { - if (!apiKey) return null; - - if (config.auth.adminApiKey && constantTimeSecretEqual(apiKey, config.auth.adminApiKey)) { - return { - id: 'admin', - label: 'Bootstrap admin key', - key_prefix: apiKey.slice(0, 8), - scopes: ['admin'], - created_at: null, - last_used_at: new Date().toISOString(), - }; - } - - const hashed = hashApiKey(apiKey); - const id = await cache.get(hashPath(hashed)); - if (!id) return null; - - const record = await getKey(id); - if (!record || record.key_hash !== hashed) return null; - - return touch(record); -} - -module.exports = { - createKey, - getKey, - hashApiKey, - listKeys, - revokeKey, - validateApiKey, -}; diff --git a/src/services/cache.js b/src/services/cache.js deleted file mode 100644 index 51b3fde..0000000 --- a/src/services/cache.js +++ /dev/null @@ -1,66 +0,0 @@ -const Redis = require('ioredis'); -const config = require('../config'); -const logger = require('../logger'); - -let client = null; - -function getClient() { - if (!client) { - client = new Redis(config.redis.url, { - lazyConnect: true, - enableOfflineQueue: false, - }); - client.on('error', (err) => { - logger.error('Redis connection error', { error: err.message }); - }); - client.on('connect', () => { - logger.info('Redis connected'); - }); - client.on('ready', () => { - logger.info('Redis ready'); - }); - // Kick off the initial connection without blocking or throwing here; - // errors are surfaced via the 'error' event above. - client.connect().catch(() => {}); - } - return client; -} - -function isConnected() { - return client !== null && client.status === 'ready'; -} - -async function get(key) { - const redis = getClient(); - const data = await redis.get(key); - if (!data) return null; - try { - return JSON.parse(data); - } catch { - return data; - } -} - -async function set(key, value, ttlSeconds) { - const redis = getClient(); - const serialized = JSON.stringify(value); - if (ttlSeconds) { - await redis.setex(key, ttlSeconds, serialized); - } else { - await redis.set(key, serialized); - } -} - -async function del(key) { - const redis = getClient(); - await redis.del(key); -} - -async function disconnect() { - if (client) { - await client.quit(); - client = null; - } -} - -module.exports = { get, set, del, disconnect, getClient, isConnected }; diff --git a/src/services/leaderElection.js b/src/services/leaderElection.js deleted file mode 100644 index d288f7b..0000000 --- a/src/services/leaderElection.js +++ /dev/null @@ -1,304 +0,0 @@ -'use strict'; - -/** - * Redis-based distributed lock (lease) for leader election. - * - * Implements a single-key lease using SET NX PX for acquisition and a Lua - * script for atomic check-and-renew, following the same Lua-atomic pattern - * used elsewhere in this codebase (see deliveryRepository.js). - * - * Lock keys follow the convention `leader:` so each background - * job type can have its own independent leader. - * - * Failover window: - * If the leader process dies without releasing its lease, the lease will - * expire automatically after LEASE_TTL_MS milliseconds. A follower will - * detect the expired lease on its next renewal check (renewal interval) - * and attempt to acquire leadership. The maximum failover time is bounded - * by LEASE_TTL_MS + LEASE_RENEW_INTERVAL_MS (with jitter). - * - * Example with defaults (LEASE_TTL_MS=15000, LEASE_RENEW_INTERVAL_MS=5000): - * Worst-case failover: ~20s (15s TTL + 5s check interval) - * Typical failover: ~7-15s (TTL expires; next check detects it) - */ - -const crypto = require('crypto'); -const os = require('os'); -const cache = require('./cache'); -const logger = require('../logger'); -const config = require('../config'); - -// Lua script: atomically renew a lease only if we still hold it. -// KEYS[1] — lock key (e.g. "leader:price_refresh") -// ARGV[1] — expected instance id (our id) -// ARGV[2] — new TTL in milliseconds -// Returns 1 if renewed, 0 if we no longer hold the lease. -const RENEW_LUA = ` -if redis.call('GET', KEYS[1]) == ARGV[1] then - return redis.call('PEXPIRE', KEYS[1], ARGV[2]) -end -return 0 -`; - -// Lua script: atomically release a lease only if we still hold it. -// KEYS[1] — lock key -// ARGV[1] — expected instance id -// Returns 1 if released, 0 if we didn't hold it. -const RELEASE_LUA = ` -if redis.call('GET', KEYS[1]) == ARGV[1] then - return redis.call('DEL', KEYS[1]) -end -return 0 -`; - -let ensureCommandsRegistered = false; - -function registerLuaCommands(redis) { - if (ensureCommandsRegistered) return; - redis.defineCommand('renewLease', { numberOfKeys: 1, lua: RENEW_LUA }); - redis.defineCommand('releaseLease', { numberOfKeys: 1, lua: RELEASE_LUA }); - ensureCommandsRegistered = true; -} - -/** - * Creates a leader-election instance for a named job. - * - * @param {string} jobName - Logical job name (e.g. "price_refresh", "webhook_retry", "airdrop_expiry") - * @param {object} [opts] - Optional overrides - * @param {number} [opts.leaseTtlMs] - Lease TTL in milliseconds (default: config.leaderElection.leaseTtlMs) - * @param {number} [opts.renewIntervalMs] - How often to attempt renewal (default: config.leaderElection.renewIntervalMs) - * @param {string} [opts.instanceId] - This instance's identifier (default: config.leaderElection.instanceId) - * @returns {object} Leader election interface - */ -function createLeaderElection(jobName, opts = {}) { - const lockKey = `leader:${jobName}`; - const instanceId = opts.instanceId || config.leaderElection.instanceId; - const leaseTtlMs = opts.leaseTtlMs || config.leaderElection.leaseTtlMs; - const renewIntervalMs = opts.renewIntervalMs || config.leaderElection.renewIntervalMs; - - let leader = false; - let renewTimer = null; - let acquiredAt = null; - let lastRenewedAt = null; - - /** - * Attempt to acquire the leader lease. - * Returns true if acquired, false if someone else holds it. - */ - async function tryAcquire() { - const redis = cache.getClient(); - registerLuaCommands(redis); - - const result = await redis.set(lockKey, instanceId, 'NX', 'PX', leaseTtlMs); - if (result === 'OK') { - if (!leader) { - logger.info('Acquired leader lease', { job: jobName, instanceId, lockKey, leaseTtlMs }); - } - leader = true; - acquiredAt = Date.now(); - lastRenewedAt = Date.now(); - return true; - } - - if (leader) { - // We thought we were leader but can't acquire — someone else has it. - // This shouldn't normally happen with proper renewal, but handles edge - // cases like a long GC pause causing lease expiry. - logger.warn('Lost leader lease — another instance has acquired it', { - job: jobName, - instanceId, - lockKey, - }); - leader = false; - acquiredAt = null; - lastRenewedAt = null; - } - - return false; - } - - /** - * Attempt to renew the lease. Returns true if renewal succeeded (we still - * hold the lease), false if we lost it. - */ - async function renew() { - if (!leader) return false; - - const redis = cache.getClient(); - registerLuaCommands(redis); - - try { - const result = await redis.renewLease(lockKey, instanceId, leaseTtlMs); - if (result === 1) { - lastRenewedAt = Date.now(); - return true; - } - - // Lease expired and someone else took it, or it was manually deleted. - logger.warn('Failed to renew leader lease — lost leadership', { - job: jobName, - instanceId, - lockKey, - }); - leader = false; - acquiredAt = null; - lastRenewedAt = null; - return false; - } catch (err) { - logger.error('Leader lease renewal error', { - job: jobName, - instanceId, - lockKey, - error: err.message, - }); - // Don't clear leader flag on transient Redis errors — the lease may - // still be valid. We'll retry on the next renewal cycle. - return leader; - } - } - - /** - * Release the lease explicitly. Called during graceful shutdown. - */ - async function release() { - if (!leader) return; - - const redis = cache.getClient(); - registerLuaCommands(redis); - - try { - await redis.releaseLease(lockKey, instanceId); - logger.info('Released leader lease', { job: jobName, instanceId, lockKey }); - } catch (err) { - logger.error('Error releasing leader lease', { - job: jobName, - instanceId, - lockKey, - error: err.message, - }); - } - - leader = false; - acquiredAt = null; - lastRenewedAt = null; - } - - /** - * Start the periodic renewal loop. - */ - function startRenewLoop() { - if (renewTimer) return; - stopRenewLoop(); - - // Try to acquire immediately on start - tryAcquire().catch((err) => { - logger.error('Leader election initial acquire failed', { - job: jobName, - instanceId, - error: err.message, - }); - }); - - renewTimer = setInterval(() => { - if (leader) { - // We hold the lease — try to renew it - renew().catch((err) => { - logger.error('Leader election renewal loop error', { - job: jobName, - instanceId, - error: err.message, - }); - }); - } else { - // We don't hold the lease — try to acquire - tryAcquire().catch((err) => { - logger.error('Leader election acquire retry failed', { - job: jobName, - instanceId, - error: err.message, - }); - }); - } - }, renewIntervalMs); - - if (typeof renewTimer.unref === 'function') { - renewTimer.unref(); - } - - logger.info('Leader election renewal loop started', { - job: jobName, - instanceId, - lockKey, - renewIntervalMs, - leaseTtlMs, - }); - } - - /** - * Stop the periodic renewal loop and release the lease. - */ - async function stopRenewLoop() { - if (renewTimer) { - clearInterval(renewTimer); - renewTimer = null; - } - await release(); - logger.info('Leader election renewal loop stopped', { job: jobName, instanceId }); - } - - /** - * Returns whether this instance currently holds the leader lease. - */ - function isLeader() { - return leader; - } - - /** - * Returns diagnostic info about the current leadership state. - */ - function getState() { - return { - isLeader: leader, - instanceId, - lockKey, - leaseTtlMs, - renewIntervalMs, - acquiredAt: acquiredAt ? new Date(acquiredAt).toISOString() : null, - lastRenewedAt: lastRenewedAt ? new Date(lastRenewedAt).toISOString() : null, - }; - } - - /** - * Fetch the current lease holder from Redis (external view). - */ - async function getCurrentLeader() { - try { - const redis = cache.getClient(); - return await redis.get(lockKey); - } catch (err) { - logger.error('Error fetching current leader', { - job: jobName, - lockKey, - error: err.message, - }); - return null; - } - } - - return { - tryAcquire, - renew, - release, - startRenewLoop, - stopRenewLoop, - isLeader, - getState, - getCurrentLeader, - jobName, - lockKey, - instanceId, - }; -} - -module.exports = { createLeaderElection }; - diff --git a/src/services/priceOracle.js b/src/services/priceOracle.js deleted file mode 100644 index 6147e9f..0000000 --- a/src/services/priceOracle.js +++ /dev/null @@ -1,287 +0,0 @@ -const cache = require('./cache'); -const stellarDex = require('./sources/stellarDex'); -const coingecko = require('./sources/coingecko'); -const coinmarketcap = require('./sources/coinmarketcap'); -const config = require('../config'); -const logger = require('../logger'); -const { CircuitBreaker } = require('../utils/circuitBreaker'); - -const CACHE_PREFIX = 'price:'; -const HISTORY_PREFIX = 'price:history:'; -const breakerOptions = config.price.circuitBreaker; -const SOURCES = [ - { - name: 'stellar_dex', - fetch: stellarDex.fetchPrice, - breaker: new CircuitBreaker('stellar_dex', breakerOptions), - }, - { - name: 'coingecko', - fetch: coingecko.fetchPrice, - breaker: new CircuitBreaker('coingecko', breakerOptions), - getCircuitState: coingecko.getCircuitState, - }, - { - name: 'coinmarketcap', - fetch: coinmarketcap.fetchPrice, - breaker: new CircuitBreaker('coinmarketcap', breakerOptions), - getCircuitState: coinmarketcap.getCircuitState, - }, -]; - -/** - * Circuit-breaker state for every source that has one (currently coingecko - * and coinmarketcap — stellar_dex has no API-key/auth failure mode). Lets - * callers (e.g. /health) see at a glance which price sources are currently - * skipped due to a nonRetryable failure. See #95. - */ -function getSourceCircuitStates() { - return SOURCES.filter((source) => typeof source.getCircuitState === 'function').map((source) => - source.getCircuitState() - ); -} - -function median(values) { - if (values.length === 0) return null; - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - if (sorted.length % 2 === 0) { - return (sorted[mid - 1] + sorted[mid]) / 2; - } - return sorted[mid]; -} - -function buildCacheKey(assetCode, issuer) { - if (!issuer) return `${CACHE_PREFIX}${assetCode}`; - return `${CACHE_PREFIX}${assetCode}:${issuer}`; -} - -function buildHistoryKey(assetCode, issuer) { - if (!issuer) return `${HISTORY_PREFIX}${assetCode}`; - return `${HISTORY_PREFIX}${assetCode}:${issuer}`; -} - -async function detectAnomaly(currentPrice, assetCode, issuer) { - const historyKey = buildHistoryKey(assetCode, issuer); - - let history = null; - try { - history = await cache.get(historyKey); - } catch (err) { - logger.warn('Cache read failed in anomaly detection, skipping', { error: err.message }); - return false; - } - - if (!history || !history.price || history.price <= 0) { - try { - await cache.set(historyKey, { price: currentPrice, timestamp: Date.now() }, 3600); - } catch (err) { - logger.warn('Cache write failed in anomaly detection', { error: err.message }); - } - return false; - } - - const changePercent = Math.abs((currentPrice - history.price) / history.price) * 100; - - if (changePercent > config.price.anomalyThresholdPercent) { - logger.warn('Price anomaly detected', { - assetCode, - issuer, - previousPrice: history.price, - currentPrice, - changePercent: changePercent.toFixed(2), - }); - } - - try { - await cache.set(historyKey, { price: currentPrice, timestamp: Date.now() }, 3600); - } catch (err) { - logger.warn('Cache write failed in anomaly detection', { error: err.message }); - } - - return changePercent > config.price.anomalyThresholdPercent; -} - -async function fetchFromAllSources(assetCode, issuer) { - const results = []; - - for (const source of SOURCES) { - try { - const price = await source.breaker.call(() => source.fetch(assetCode, issuer)); - if (price !== null && price > 0) { - results.push({ source: source.name, price }); - } - } catch (err) { - logger.warn('Source fetch failed', { source: source.name, assetCode, error: err.message }); - } - } - - return results; -} - -function getCircuitStates() { - return SOURCES.reduce((states, source) => { - states[source.name] = source.breaker.getState(); - return states; - }, {}); -} - -function resetCircuitBreakers() { - for (const source of SOURCES) { - source.breaker.reset(); - } -} - -async function getPrice(assetCode, issuer = null) { - const cacheKey = buildCacheKey(assetCode, issuer); - let redisUnavailable = false; - - try { - const cached = await cache.get(cacheKey); - if (cached) { - const ageMs = Date.now() - cached.fetchedAt; - const ageMinutes = ageMs / 60000; - const isStale = ageMinutes > config.price.staleThresholdMinutes; - - return { - asset_code: assetCode, - issuer: issuer || null, - price_usd: cached.price, - source: cached.source, - fetched_at: new Date(cached.fetchedAt).toISOString(), - is_stale: isStale, - stale_warning: isStale - ? `Price is ${ageMinutes.toFixed(1)} minutes old (threshold: ${config.price.staleThresholdMinutes} min)` - : null, - sources_attempted: cached.sourcesAttempted || [], - redis_unavailable: false, - }; - } - } catch (err) { - logger.warn('Cache read failed, falling back to source fetch', { error: err.message }); - redisUnavailable = true; - } - - return fetchFreshPrice(assetCode, issuer, redisUnavailable); -} - -async function fetchFreshPrice(assetCode, issuer = null, redisUnavailable = false) { - const sourceResults = await fetchFromAllSources(assetCode, issuer); - const sourcesAttempted = sourceResults.map((r) => r.source); - const prices = sourceResults.map((r) => r.price); - - const aggregatedPrice = median(prices); - - if (aggregatedPrice === null) { - logger.warn('No price sources available', { assetCode, issuer }); - return { - asset_code: assetCode, - issuer: issuer || null, - price_usd: null, - source: 'unavailable', - fetched_at: new Date().toISOString(), - is_stale: true, - stale_warning: 'No price data available from any source', - sources_attempted: sourcesAttempted, - redis_unavailable: redisUnavailable, - }; - } - - const primarySource = sourceResults.length > 0 ? sourceResults[0].source : 'aggregated'; - - if (!redisUnavailable) { - await detectAnomaly(aggregatedPrice, assetCode, issuer); - } - - if (!redisUnavailable) { - try { - const cacheKey = buildCacheKey(assetCode, issuer); - await cache.set( - cacheKey, - { - price: aggregatedPrice, - source: primarySource, - fetchedAt: Date.now(), - sourcesAttempted, - }, - config.price.cacheTtl - ); - } catch (err) { - logger.warn('Cache write failed, continuing without caching', { error: err.message }); - redisUnavailable = true; - } - } - - return { - asset_code: assetCode, - issuer: issuer || null, - price_usd: aggregatedPrice, - source: primarySource, - fetched_at: new Date().toISOString(), - is_stale: false, - stale_warning: null, - sources_attempted: sourcesAttempted, - redis_unavailable: redisUnavailable, - }; -} - -async function refreshAllCachedPrices() { - if (!cache.isConnected()) { - logger.warn('Redis unavailable, skipping scheduled price refresh cycle'); - return; - } - - const redis = cache.getClient(); - const keys = []; - let cursor = '0'; - - try { - do { - const result = await redis.scan(cursor, 'MATCH', `${CACHE_PREFIX}*`, 'COUNT', 100); - cursor = result[0]; - keys.push(...result[1]); - } while (cursor !== '0'); - } catch (err) { - logger.warn('Redis scan failed during price refresh, aborting cycle', { error: err.message }); - return; - } - - const freshPrices = {}; - - const refreshPromises = keys - .filter((key) => !key.includes(':history:')) - .map(async (key) => { - const suffix = key.replace(CACHE_PREFIX, ''); - const parts = suffix.split(':'); - const assetCode = parts[0]; - const issuer = parts.length > 1 ? parts[1] : null; - const assetKey = issuer ? `${assetCode}:${issuer}` : assetCode; - - try { - const result = await fetchFreshPrice(assetCode, issuer); - if (result && result.price_usd !== null) { - freshPrices[assetKey] = { price: result.price_usd, source: result.source }; - } - logger.debug('Refreshed price', { assetCode, issuer }); - } catch (err) { - logger.warn('Price refresh failed', { assetCode, issuer, error: err.message }); - } - }); - - await Promise.allSettled(refreshPromises); - logger.info('Price refresh cycle completed', { keysRefreshed: keys.length }); - return freshPrices; -} - -module.exports = { - getPrice, - fetchFreshPrice, - getCircuitStates, - resetCircuitBreakers, - refreshAllCachedPrices, - // Internal helpers exported for unit testing. - median, - detectAnomaly, - fetchFromAllSources, - getSourceCircuitStates, -}; diff --git a/src/services/sources/circuitBreaker.js b/src/services/sources/circuitBreaker.js deleted file mode 100644 index 8d60d21..0000000 --- a/src/services/sources/circuitBreaker.js +++ /dev/null @@ -1,70 +0,0 @@ -'use strict'; - -const logger = require('../../logger'); - -/** - * A per-source circuit breaker for permanent (nonRetryable) failures like an - * invalid/revoked API key. Distinct from ordinary transient failures (network - * blips, rate limits): those already self-heal on the next fetch cycle and - * are intentionally left untouched by this module. - * - * State is process-local (module-level, one instance per source per - * process) — acceptable because it only affects retry cadence, not - * correctness; each horizontally-scaled replica independently rate-limits - * its own calls to a known-broken source rather than sharing a single - * circuit (see #98 for the analogous cross-replica coordination gap in - * scheduled jobs). - */ -function createCircuitBreaker({ sourceName, cooldownMs, reminderIntervalMs }) { - let openUntil = 0; - let lastReminderLoggedAt = 0; - - function isOpen() { - return Date.now() < openUntil; - } - - /** Call when a fetch is skipped because the circuit is open. Logs at most once per reminderIntervalMs, not once per skipped attempt. */ - function noteSkipped(context = {}) { - const now = Date.now(); - if (now - lastReminderLoggedAt >= reminderIntervalMs) { - logger.warn('Price source circuit open, skipping fetch', { - source: sourceName, - openUntil: new Date(openUntil).toISOString(), - ...context, - }); - lastReminderLoggedAt = now; - } - } - - /** Call on a nonRetryable failure. Logs distinctly (error level) only the first time the circuit transitions from closed to open. */ - function open(context = {}) { - const wasOpen = isOpen(); - openUntil = Date.now() + cooldownMs; - if (!wasOpen) { - logger.error('Price source permanently misconfigured', { - source: sourceName, - cooldownMs, - ...context, - }); - lastReminderLoggedAt = Date.now(); - } - } - - /** Call on a successful fetch. No-op if the circuit was already closed. */ - function close() { - openUntil = 0; - lastReminderLoggedAt = 0; - } - - function getState() { - return { - source: sourceName, - open: isOpen(), - openUntil: openUntil ? new Date(openUntil).toISOString() : null, - }; - } - - return { isOpen, noteSkipped, open, close, getState }; -} - -module.exports = { createCircuitBreaker }; diff --git a/src/services/sources/coingecko.js b/src/services/sources/coingecko.js deleted file mode 100644 index d8c93c3..0000000 --- a/src/services/sources/coingecko.js +++ /dev/null @@ -1,84 +0,0 @@ -const axios = require('axios'); -const config = require('../../config'); -const logger = require('../../logger'); -const { createCircuitBreaker } = require('./circuitBreaker'); - -const STELLAR_COINGECKO_MAP = { - XLM: 'stellar', -}; - -const circuit = createCircuitBreaker({ - sourceName: 'coingecko', - cooldownMs: config.priceSources.circuitCooldownMs, - reminderIntervalMs: config.priceSources.circuitReminderIntervalMs, -}); - -let apiClient = null; - -function getClient() { - if (!apiClient) { - const headers = { Accept: 'application/json' }; - if (config.coingecko.apiKey) { - headers['x-cg-demo-api-key'] = config.coingecko.apiKey; - } - apiClient = axios.create({ - baseURL: config.coingecko.baseUrl, - headers, - timeout: 10000, - }); - } - return apiClient; -} - -async function fetchPrice(assetCode) { - const coinId = STELLAR_COINGECKO_MAP[assetCode]; - if (!coinId) { - logger.debug('Asset not supported by CoinGecko', { assetCode }); - return null; - } - - if (circuit.isOpen()) { - circuit.noteSkipped({ assetCode }); - return null; - } - - try { - const client = getClient(); - const response = await client.get('/simple/price', { - params: { - ids: coinId, - vs_currencies: 'usd', - }, - }); - - // A successful HTTP round-trip means any configured API key is valid, - // regardless of whether this particular coin had usable price data. - circuit.close(); - - const price = response.data[coinId]?.usd; - if (price === undefined || price === null) { - return null; - } - - return price; - } catch (err) { - if (err.response?.status === 401) { - // Per CoinGecko's docs, 401 means a missing/invalid API key — a - // permanent misconfiguration, not something that self-heals on - // retry. Distinct from 403 (CDN/firewall block) and 429 (rate - // limit), neither of which indicate a bad key. - err.nonRetryable = true; - circuit.open({ assetCode }); - logger.warn('CoinGecko authentication failed', { assetCode }); - throw err; - } - if (err.response?.status === 429) { - logger.warn('CoinGecko rate limit hit', { assetCode }); - } else { - logger.warn('CoinGecko price fetch failed', { assetCode, error: err.message }); - } - return null; - } -} - -module.exports = { fetchPrice, getCircuitState: circuit.getState }; diff --git a/src/services/sources/coinmarketcap.js b/src/services/sources/coinmarketcap.js deleted file mode 100644 index 0ce3f73..0000000 --- a/src/services/sources/coinmarketcap.js +++ /dev/null @@ -1,110 +0,0 @@ -const axios = require('axios'); -const config = require('../../config'); -const logger = require('../../logger'); -const { createCircuitBreaker } = require('./circuitBreaker'); - -const circuit = createCircuitBreaker({ - sourceName: 'coinmarketcap', - cooldownMs: config.priceSources.circuitCooldownMs, - reminderIntervalMs: config.priceSources.circuitReminderIntervalMs, -}); - -let apiClient = null; - -function getClient() { - if (!apiClient) { - apiClient = axios.create({ - baseURL: config.coinmarketcap.baseUrl, - headers: { - 'Accept': 'application/json', - 'X-CMC_PRO_API_KEY': config.coinmarketcap.apiKey, - }, - timeout: 10000, - }); - } - return apiClient; -} - -function resolveMarket(assetCode, issuer) { - const normalizedIssuer = issuer || null; - - if (normalizedIssuer) { - const market = config.coinmarketcap.assetIssuerMap?.[`${assetCode}:${normalizedIssuer}`]; - if (!market) { - logger.debug('Issuer not supported by CoinMarketCap', { assetCode, issuer: normalizedIssuer }); - return null; - } - return market; - } - - const market = config.coinmarketcap.assetIssuerMap?.[assetCode]; - if (!market) { - logger.debug('Asset not supported by CoinMarketCap', { assetCode, issuer: normalizedIssuer }); - return null; - } - - if (market === null) { - logger.debug('Issuer not supported by CoinMarketCap', { assetCode, issuer: normalizedIssuer }); - return null; - } - - return market; -} - -async function fetchPrice(assetCode, issuer = null) { - if (!config.coinmarketcap.apiKey) { - logger.debug('CoinMarketCap API key not configured'); - return null; - } - - const market = resolveMarket(assetCode, issuer); - if (!market) { - return null; - } - - if (circuit.isOpen()) { - circuit.noteSkipped({ assetCode }); - return null; - } - - try { - const client = getClient(); - const lookupKey = market.id ? String(market.id) : market.symbol; - const response = await client.get('/cryptocurrency/quotes/latest', { - params: { - ...(market.id ? { id: market.id } : { symbol: market.symbol }), - convert: 'USD', - }, - }); - - // A successful HTTP round-trip means the API key is valid, regardless - // of whether this particular asset had usable quote data — close the - // circuit before evaluating the response shape. - circuit.close(); - - const data = response.data?.data?.[lookupKey]; - if (!data || !data.quote?.USD?.price) { - return null; - } - - return data.quote.USD.price; - } catch (err) { - if (err.response?.status === 401) { - err.nonRetryable = true; - circuit.open({ assetCode }); - logger.warn('CoinMarketCap authentication failed', { assetCode }); - throw err; - } - if (err.response?.status === 429) { - logger.warn('CoinMarketCap rate limit hit', { - assetCode, - retry_after: err.response.headers?.['retry-after'] || null, - }); - } else { - logger.warn('CoinMarketCap price fetch failed', { assetCode, error: err.message }); - } - return null; - } -} - -module.exports = { fetchPrice, getCircuitState: circuit.getState }; diff --git a/src/services/sources/stellarDex.js b/src/services/sources/stellarDex.js deleted file mode 100644 index 8e2c43d..0000000 --- a/src/services/sources/stellarDex.js +++ /dev/null @@ -1,84 +0,0 @@ -const { Asset, Horizon } = require('stellar-sdk'); -const config = require('../../config'); -const logger = require('../../logger'); - -let server = null; - -function getServer() { - if (!server) { - server = new Horizon.Server(config.stellar.horizonUrl); - } - return server; -} - -function xlmAsset() { - return Asset.native(); -} - -function usdcAsset() { - return new Asset('USDC', config.stellar.usdcIssuer); -} - -function issuedAsset(assetCode, issuer) { - return new Asset(assetCode, issuer); -} - -function midpointFromOrderBook(orderBook) { - const bidPrice = orderBook.bids?.[0]?.price; - const askPrice = orderBook.asks?.[0]?.price; - const bestBid = bidPrice !== undefined ? parseFloat(bidPrice) : null; - const bestAsk = askPrice !== undefined ? parseFloat(askPrice) : null; - const hasBid = Number.isFinite(bestBid) && bestBid > 0; - const hasAsk = Number.isFinite(bestAsk) && bestAsk > 0; - - if (hasBid && hasAsk) return (bestBid + bestAsk) / 2; - if (hasBid) return bestBid; - if (hasAsk) return bestAsk; - return null; -} - -async function fetchOrderBookMidpoint(horizon, base, counter) { - const orderBook = await horizon.orderbook(base, counter).limit(1).call(); - return midpointFromOrderBook(orderBook); -} - -async function fetchPrice(assetCode, issuer) { - try { - const horizon = getServer(); - const normalizedCode = assetCode.toUpperCase(); - - if (!issuer && normalizedCode !== 'XLM') { - logger.debug('Stellar DEX issuer required for issued asset', { assetCode }); - return null; - } - - if (normalizedCode === 'XLM') { - return await fetchOrderBookMidpoint(horizon, xlmAsset(), usdcAsset()); - } - - const assetInXlm = await fetchOrderBookMidpoint( - horizon, - issuedAsset(normalizedCode, issuer), - xlmAsset() - ); - if (assetInXlm === null) return null; - - const xlmUsd = await getXlmUsdPrice(horizon); - if (xlmUsd === null) return null; - return assetInXlm * xlmUsd; - } catch (err) { - logger.warn('Stellar DEX price fetch failed', { assetCode, issuer, error: err.message }); - throw err; - } -} - -async function getXlmUsdPrice(horizon) { - try { - return await fetchOrderBookMidpoint(horizon, xlmAsset(), usdcAsset()); - } catch (err) { - logger.warn('XLM/USDC price fetch failed', { error: err.message }); - throw err; - } -} - -module.exports = { fetchPrice }; diff --git a/src/services/webhook.js b/src/services/webhook.js deleted file mode 100644 index e3e6d79..0000000 --- a/src/services/webhook.js +++ /dev/null @@ -1,90 +0,0 @@ -const crypto = require('crypto'); -const axios = require('axios'); -const logger = require('../logger'); - -const DEFAULT_TIMEOUT_MS = 10000; - -function payloadBody(payload) { - return typeof payload === 'string' ? payload : JSON.stringify(payload); -} - -function signPayload(secret, payload, timestamp = Date.now()) { - const body = payloadBody(payload); - return crypto - .createHmac('sha256', secret) - .update(`${timestamp}.${body}`) - .digest('hex'); -} - -function buildSignatureHeaders(secret, payload, timestamp = Date.now()) { - const signature = signPayload(secret, payload, timestamp); - return { - 'Content-Type': 'application/json', - 'X-SmartDrop-Signature': `sha256=${signature}`, - 'X-SmartDrop-Timestamp': String(timestamp), - }; -} - -function verifySignature(secret, payload, signatureHeader, timestamp) { - if (!signatureHeader || !timestamp || !signatureHeader.startsWith('sha256=')) { - return false; - } - - const expected = Buffer.from(signPayload(secret, payload, timestamp), 'hex'); - const actual = Buffer.from(signatureHeader.slice('sha256='.length), 'hex'); - - return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); -} - -async function sendSignedRequest(webhookUrl, secret, payload, options = {}) { - const timestamp = options.timestamp || Date.now(); - const headers = buildSignatureHeaders(secret, payload, timestamp); - const startedAt = Date.now(); - - try { - const response = await axios.post(webhookUrl, payload, { - headers, - timeout: options.timeoutMs || DEFAULT_TIMEOUT_MS, - validateStatus: () => true, - }); - - return { - ok: response.status >= 200 && response.status < 300, - status: response.status, - duration_ms: Date.now() - startedAt, - }; - } catch (err) { - err.duration_ms = Date.now() - startedAt; - throw err; - } -} - -async function deliver(webhookUrl, secret, payload) { - try { - const result = await sendSignedRequest(webhookUrl, secret, payload); - if (result.ok) { - logger.info('Webhook delivered', { alert_id: payload.alert_id, url: webhookUrl }); - return; - } - - logger.warn('Webhook delivery failed', { - alert_id: payload.alert_id, - url: webhookUrl, - status: result.status, - }); - } catch (err) { - logger.warn('Webhook delivery failed', { - alert_id: payload.alert_id, - url: webhookUrl, - error: err.message, - }); - } -} - -module.exports = { - buildSignatureHeaders, - deliver, - sendSignedRequest, - signPayload, - verifySignature, -}; diff --git a/src/services/webhookDispatcher.js b/src/services/webhookDispatcher.js deleted file mode 100644 index bb83837..0000000 --- a/src/services/webhookDispatcher.js +++ /dev/null @@ -1,217 +0,0 @@ -'use strict'; - -const axios = require('axios'); -const config = require('../config'); -const logger = require('../logger'); -const signature = require('./webhookSignature'); -const events = require('./webhookEvents'); -const webhookRepo = require('../repositories/webhookRepository'); -const deliveryRepo = require('../repositories/deliveryRepository'); - -const USER_AGENT = 'SmartDrop-Webhooks/1.0'; - -/** - * Computes the retry delay for a webhook delivery that has completed - * `attemptsCompleted` attempts, using exponential backoff with "equal - * jitter": half of the deterministic delay is fixed, the other half is - * randomized within [0, half). This spreads out deliveries that fail at - * the same attempt count around the same wall-clock moment — preventing - * the synchronized-retry thundering-herd burst described in #128 — while - * keeping the result always within [deterministic/2, deterministic): - * never zero or negative, and never reaching or exceeding the original - * deterministic delay, so worst-case retry latency stays predictable for - * operators. "Full jitter" (uniformly random in [0, deterministic)) was - * considered and rejected: it can produce near-immediate retries, and — - * with the default 2x factor — its range for one attempt overlaps the - * next attempt's range, which would make delays non-monotonic across - * attempts. - * - * The random source is injectable via `options.random` (mirroring - * CircuitBreaker's `options.now`/`options.logger` pattern in - * `utils/circuitBreaker.js`) so tests can assert exact min/max bounds - * rather than only "looks random". - */ -function backoffMs(attemptsCompleted, options = {}) { - const random = options.random || Math.random; - const base = config.webhooks.retryBaseMs; - const factor = config.webhooks.retryFactor; - const deterministicDelay = base * factor ** (attemptsCompleted - 1); - const half = deterministicDelay / 2; - return half + random() * half; -} - -function shouldRetry(responseStatus, networkError) { - if (networkError) return true; - if (responseStatus == null) return true; - if (responseStatus >= 500 && responseStatus < 600) return true; - if (responseStatus === 408 || responseStatus === 429) return true; - return false; -} - -function buildHeaders(secret, body, eventType, deliveryId) { - return { - 'Content-Type': 'application/json', - 'User-Agent': USER_AGENT, - 'X-SmartDrop-Event': eventType, - 'X-SmartDrop-Delivery': deliveryId, - 'X-SmartDrop-Signature': signature.sign(secret, body), - }; -} - -async function postOnce(url, headers, body) { - return axios.post(url, body, { - headers, - timeout: config.webhooks.timeoutMs, - transformRequest: [(data) => data], - validateStatus: () => true, - }); -} - -async function attempt(deliveryId) { - const delivery = await deliveryRepo.findById(deliveryId); - if (!delivery) { - logger.warn('Delivery missing, dropping retry', { delivery_id: deliveryId }); - return null; - } - if (delivery.status === 'success') return delivery; - - const webhook = await webhookRepo.findById(delivery.webhook_id); - if (!webhook || !webhook.active) { - return deliveryRepo.update(deliveryId, { - status: 'failed', - last_error: 'webhook missing or inactive', - last_attempt_at: new Date().toISOString(), - next_retry_at: null, - }); - } - - const payload = delivery.payload || { - event: delivery.event_type, - event_id: delivery.event_id, - delivery_id: delivery.id, - occurred_at: delivery.created_at, - }; - const body = JSON.stringify(payload); - const headers = buildHeaders(webhook.secret, body, delivery.event_type, delivery.id); - - const attempts = delivery.attempts + 1; - let responseStatus = null; - let networkError = null; - - try { - const res = await postOnce(webhook.url, headers, body); - responseStatus = res.status; - } catch (err) { - networkError = err.message || 'network error'; - } - - const succeeded = responseStatus != null && responseStatus >= 200 && responseStatus < 300; - const nowIso = new Date().toISOString(); - - if (succeeded) { - logger.info('Webhook delivered', { - delivery_id: delivery.id, - webhook_id: webhook.id, - attempts, - status: responseStatus, - }); - return deliveryRepo.update(deliveryId, { - status: 'success', - attempts, - last_attempt_at: nowIso, - next_retry_at: null, - last_error: null, - response_status: responseStatus, - }); - } - - const errorMessage = networkError || `HTTP ${responseStatus}`; - const retryable = shouldRetry(responseStatus, Boolean(networkError)); - const hasAttemptsLeft = attempts < config.webhooks.maxAttempts; - - if (retryable && hasAttemptsLeft) { - const delayMs = backoffMs(attempts); - const nextRetryAt = new Date(Date.now() + delayMs).toISOString(); - await deliveryRepo.scheduleRetry(delivery.id, Date.now() + delayMs); - logger.warn('Webhook delivery failed, retry scheduled', { - delivery_id: delivery.id, - webhook_id: webhook.id, - attempts, - error: errorMessage, - next_retry_at: nextRetryAt, - }); - return deliveryRepo.update(deliveryId, { - status: 'pending', - attempts, - last_attempt_at: nowIso, - next_retry_at: nextRetryAt, - last_error: errorMessage, - response_status: responseStatus, - }); - } - - logger.error('Webhook delivery failed permanently', { - delivery_id: delivery.id, - webhook_id: webhook.id, - attempts, - error: errorMessage, - }); - return deliveryRepo.update(deliveryId, { - status: 'failed', - attempts, - last_attempt_at: nowIso, - next_retry_at: null, - last_error: errorMessage, - response_status: responseStatus, - }); -} - -async function deliverToWebhook(webhook, eventType, eventId, payload) { - const delivery = await deliveryRepo.create({ - webhook_id: webhook.id, - event_id: eventId, - event_type: eventType, - }); - await deliveryRepo.update(delivery.id, { payload }); - return attempt(delivery.id); -} - -async function dispatch({ event_type: eventType, event_id: eventId, data }) { - if (!events.isKnownEvent(eventType)) { - logger.warn('Dispatch skipped, unknown event type', { event_type: eventType }); - return []; - } - if (!eventId || typeof eventId !== 'string') { - throw new Error('event_id is required to dispatch a webhook event'); - } - - const targets = await webhookRepo.listActiveForEvent(eventType, events.matchesSubscription); - if (targets.length === 0) return []; - - const occurredAt = new Date().toISOString(); - const payload = { - event: eventType, - event_id: eventId, - occurred_at: occurredAt, - data: data || {}, - }; - - return Promise.all( - targets.map((webhook) => deliverToWebhook(webhook, eventType, eventId, payload)) - ); -} - -async function sendTest(webhookId) { - const webhook = await webhookRepo.findById(webhookId); - if (!webhook) return null; - const eventType = 'pool.assets_locked'; - const payload = { - event: eventType, - event_id: `evt_test_${Date.now()}`, - occurred_at: new Date().toISOString(), - data: { test: true, message: 'This is a test delivery from SmartDrop' }, - }; - return deliverToWebhook(webhook, eventType, payload.event_id, payload); -} - -module.exports = { dispatch, attempt, sendTest, backoffMs, shouldRetry }; diff --git a/src/services/webhookEvents.js b/src/services/webhookEvents.js deleted file mode 100644 index 76164ef..0000000 --- a/src/services/webhookEvents.js +++ /dev/null @@ -1,51 +0,0 @@ -'use strict'; - -const POOL_EVENTS = Object.freeze([ - 'pool.created', - 'pool.assets_locked', - 'pool.assets_unlocked', - 'pool.rewards_distributed', - 'pool.closed', -]); - -const PRICE_EVENTS = Object.freeze(['price.alert']); - -// Only 'airdrop.failed' is registered here — it's the one event this -// codebase actually dispatches today (the expiry reconciliation job, #88). -// The README also documents airdrop.created/executing/completed, but -// nothing in the codebase dispatches those yet; registering unused event -// names here would let a client subscribe to something that can never -// fire, so they're left out until whatever feature actually dispatches -// them lands. -const AIRDROP_EVENTS = Object.freeze(['airdrop.failed']); - -const ALL_EVENTS = Object.freeze([...POOL_EVENTS, ...PRICE_EVENTS, ...AIRDROP_EVENTS]); -const EVENT_SET = new Set(ALL_EVENTS); - -const WILDCARD = '*'; - -function isKnownEvent(eventType) { - return typeof eventType === 'string' && EVENT_SET.has(eventType); -} - -function isValidSubscription(events) { - if (!Array.isArray(events) || events.length === 0) return false; - return events.every((e) => e === WILDCARD || EVENT_SET.has(e)); -} - -function matchesSubscription(subscribedEvents, eventType) { - if (!Array.isArray(subscribedEvents) || subscribedEvents.length === 0) return false; - if (subscribedEvents.includes(WILDCARD)) return true; - return subscribedEvents.includes(eventType); -} - -module.exports = { - POOL_EVENTS, - PRICE_EVENTS, - AIRDROP_EVENTS, - ALL_EVENTS, - WILDCARD, - isKnownEvent, - isValidSubscription, - matchesSubscription, -}; diff --git a/src/services/webhookSignature.js b/src/services/webhookSignature.js deleted file mode 100644 index 867ab8c..0000000 --- a/src/services/webhookSignature.js +++ /dev/null @@ -1,36 +0,0 @@ -'use strict'; - -const crypto = require('crypto'); - -const SIGNATURE_PREFIX = 'sha256='; - -function sign(secret, body) { - if (typeof secret !== 'string' || secret.length === 0) { - throw new Error('signature secret must be a non-empty string'); - } - const payload = typeof body === 'string' ? body : JSON.stringify(body); - const digest = crypto.createHmac('sha256', secret).update(payload).digest('hex'); - return `${SIGNATURE_PREFIX}${digest}`; -} - -function verify(secret, body, providedSignature) { - if (typeof providedSignature !== 'string' || !providedSignature.startsWith(SIGNATURE_PREFIX)) { - return false; - } - let expected; - try { - expected = sign(secret, body); - } catch { - return false; - } - const a = Buffer.from(expected); - const b = Buffer.from(providedSignature); - if (a.length !== b.length) return false; - return crypto.timingSafeEqual(a, b); -} - -function generateSecret(bytes = 32) { - return `whsec_${crypto.randomBytes(bytes).toString('hex')}`; -} - -module.exports = { sign, verify, generateSecret, SIGNATURE_PREFIX }; diff --git a/src/services/webhooks.js b/src/services/webhooks.js deleted file mode 100644 index db3cc2a..0000000 --- a/src/services/webhooks.js +++ /dev/null @@ -1,249 +0,0 @@ -const crypto = require('crypto'); -const cache = require('./cache'); -const webhook = require('./webhook'); -const logger = require('../logger'); - -const ENDPOINT_IDS_KEY = 'webhooks:endpoints'; -const DEAD_LETTER_IDS_KEY = 'webhooks:dead_letters'; -const DELIVERY_ATTEMPTS = 5; -const BACKOFF_MS = [1000, 5000, 30000, 120000, 600000]; -const VALID_EVENTS = [ - 'airdrop.created', - 'airdrop.executing', - 'airdrop.completed', - 'airdrop.failed', - 'recipient.claimed', -]; - -function endpointKey(id) { - return `webhook:endpoint:${id}`; -} - -function deliveriesKey(endpointId) { - return `webhook:endpoint:${endpointId}:deliveries`; -} - -function deliveryKey(id) { - return `webhook:delivery:${id}`; -} - -function id(prefix) { - return `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`; -} - -function secretPreview(secret) { - if (!secret) return null; - return `${secret.slice(0, 4)}...${secret.slice(-4)}`; -} - -function publicEndpoint(endpoint) { - if (!endpoint) return null; - const { secret, ...rest } = endpoint; - return { - ...rest, - secret_preview: secretPreview(secret), - }; -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function createEndpoint(data) { - const endpoint = { - id: id('wh'), - url: data.url, - events: data.events, - secret: data.secret, - active: true, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; - - await cache.set(endpointKey(endpoint.id), endpoint); - await cache.getClient().sadd(ENDPOINT_IDS_KEY, endpoint.id); - - return publicEndpoint(endpoint); -} - -async function getEndpoint(id) { - return cache.get(endpointKey(id)); -} - -async function listEndpoints() { - const ids = await cache.getClient().smembers(ENDPOINT_IDS_KEY); - const endpoints = await Promise.all(ids.map(getEndpoint)); - return endpoints.filter(Boolean).map(publicEndpoint); -} - -async function removeEndpoint(id) { - const endpoint = await getEndpoint(id); - if (!endpoint) return null; - - endpoint.active = false; - endpoint.updated_at = new Date().toISOString(); - await cache.set(endpointKey(id), endpoint); - await cache.getClient().srem(ENDPOINT_IDS_KEY, id); - - return publicEndpoint(endpoint); -} - -function makeDelivery(endpoint, event, payload) { - return { - id: id('dlv'), - endpoint_id: endpoint.id, - event, - payload, - status: 'pending', - attempt_count: 0, - attempts: [], - next_retry_at: null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; -} - -async function saveDelivery(delivery) { - delivery.updated_at = new Date().toISOString(); - await cache.set(deliveryKey(delivery.id), delivery); - await cache.getClient().sadd(deliveriesKey(delivery.endpoint_id), delivery.id); -} - -async function markDeadLetter(delivery) { - delivery.status = 'dead_letter'; - delivery.next_retry_at = null; - await saveDelivery(delivery); - await cache.getClient().sadd(DEAD_LETTER_IDS_KEY, delivery.id); -} - -async function recordAttempt(delivery, attempt) { - delivery.attempt_count = attempt.attempt; - delivery.attempts.push(attempt); - delivery.status = attempt.ok ? 'delivered' : 'failed'; - delivery.next_retry_at = attempt.next_retry_at || null; - await saveDelivery(delivery); -} - -async function processDelivery(endpoint, event, payload, options = {}) { - const delivery = options.delivery || makeDelivery(endpoint, event, payload); - const transport = options.transport || webhook.sendSignedRequest; - const wait = options.sleep || sleep; - const maxAttempts = options.maxAttempts || DELIVERY_ATTEMPTS; - - await saveDelivery(delivery); - - for (let attemptNumber = delivery.attempt_count + 1; attemptNumber <= maxAttempts; attemptNumber += 1) { - try { - const result = await transport(endpoint.url, endpoint.secret, payload, { timeoutMs: 10000 }); - const ok = result.ok === true; - const shouldRetry = !ok && attemptNumber < maxAttempts; - const nextRetryAt = shouldRetry - ? new Date(Date.now() + BACKOFF_MS[attemptNumber - 1]).toISOString() - : null; - - await recordAttempt(delivery, { - attempt: attemptNumber, - ok, - status: ok ? 'delivered' : 'failed', - response_code: result.status || null, - error: ok ? null : `HTTP ${result.status}`, - duration_ms: result.duration_ms || null, - created_at: new Date().toISOString(), - next_retry_at: nextRetryAt, - }); - - if (ok) return delivery; - if (!shouldRetry) break; - await wait(BACKOFF_MS[attemptNumber - 1]); - } catch (err) { - const shouldRetry = attemptNumber < maxAttempts; - const nextRetryAt = shouldRetry - ? new Date(Date.now() + BACKOFF_MS[attemptNumber - 1]).toISOString() - : null; - - await recordAttempt(delivery, { - attempt: attemptNumber, - ok: false, - status: 'failed', - response_code: err.response ? err.response.status : null, - error: err.message, - duration_ms: err.duration_ms || null, - created_at: new Date().toISOString(), - next_retry_at: nextRetryAt, - }); - - if (!shouldRetry) break; - await wait(BACKOFF_MS[attemptNumber - 1]); - } - } - - await markDeadLetter(delivery); - logger.warn('Webhook delivery moved to dead letter queue', { - delivery_id: delivery.id, - endpoint_id: endpoint.id, - event, - }); - return delivery; -} - -async function queueDelivery(endpoint, event, payload) { - const delivery = makeDelivery(endpoint, event, payload); - await saveDelivery(delivery); - - setImmediate(() => { - processDelivery(endpoint, event, payload, { delivery }).catch((err) => { - logger.error('Webhook background delivery failed', { - delivery_id: delivery.id, - endpoint_id: endpoint.id, - error: err.message, - }); - }); - }); - - return delivery; -} - -async function deliverEvent(event, payload) { - const endpoints = await Promise.all((await cache.getClient().smembers(ENDPOINT_IDS_KEY)).map(getEndpoint)); - const deliveries = []; - - for (const endpoint of endpoints.filter(Boolean)) { - if (!endpoint.active || !endpoint.events.includes(event)) continue; - deliveries.push(await queueDelivery(endpoint, event, payload)); - } - - return deliveries; -} - -async function sendTestPing(endpointId) { - const endpoint = await getEndpoint(endpointId); - if (!endpoint || !endpoint.active) return null; - - return queueDelivery(endpoint, 'ping', { - event: 'ping', - timestamp: new Date().toISOString(), - }); -} - -async function listDeliveries(endpointId, limit = 50) { - const ids = await cache.getClient().smembers(deliveriesKey(endpointId)); - const deliveries = (await Promise.all(ids.map((deliveryId) => cache.get(deliveryKey(deliveryId))))) - .filter(Boolean) - .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); - - return deliveries.slice(0, limit); -} - -module.exports = { - BACKOFF_MS, - DELIVERY_ATTEMPTS, - VALID_EVENTS, - createEndpoint, - deliverEvent, - getEndpoint, - listDeliveries, - listEndpoints, - processDelivery, - removeEndpoint, - sendTestPing, -}; diff --git a/src/startup/cacheWarm.js b/src/startup/cacheWarm.js deleted file mode 100644 index b2498bb..0000000 --- a/src/startup/cacheWarm.js +++ /dev/null @@ -1,79 +0,0 @@ -'use strict'; - -const config = require('../config'); -const logger = require('../logger'); -const priceOracle = require('../services/priceOracle'); - -const DEFAULT_TIMEOUT_MS = 30000; - -function isWarmSuccess(result) { - return ( - result.status === 'fulfilled' && - result.value && - result.value.price_usd !== null && - result.value.redis_unavailable !== true - ); -} - -async function runWarmCache(assets, oracle) { - const startedAt = Date.now(); - const results = await Promise.allSettled( - assets.map(({ code, issuer }) => ( - Promise.resolve().then(() => oracle.fetchFreshPrice(code, issuer || null)) - )) - ); - const succeeded = results.filter(isWarmSuccess).length; - - return { - total: assets.length, - succeeded, - failed: assets.length - succeeded, - timedOut: false, - durationMs: Date.now() - startedAt, - }; -} - -async function warmCache( - assets = config.watchedAssets, - oracle = priceOracle, - { timeoutMs = DEFAULT_TIMEOUT_MS, log = logger } = {} -) { - if (!assets || assets.length === 0) { - log.info('Cache warm skipped: no watched assets configured'); - return { total: 0, succeeded: 0, failed: 0, timedOut: false, durationMs: 0 }; - } - - let timedOut = false; - let timeoutId; - - const warming = runWarmCache(assets, oracle).then((summary) => { - if (!timedOut) { - log.info('Cache warm complete', summary); - } - return summary; - }); - - const timeout = new Promise((resolve) => { - timeoutId = setTimeout(() => { - timedOut = true; - const summary = { - total: assets.length, - succeeded: 0, - failed: assets.length, - timedOut: true, - durationMs: timeoutMs, - }; - log.warn('Cache warm timed out; starting server anyway', summary); - resolve(summary); - }, timeoutMs); - }); - - const summary = await Promise.race([warming, timeout]); - if (!summary.timedOut) clearTimeout(timeoutId); - return summary; -} - -module.exports = { - warmCache, - runWarmCache, -}; diff --git a/src/stellar/stellar.module.ts b/src/stellar/stellar.module.ts new file mode 100644 index 0000000..9c757c6 --- /dev/null +++ b/src/stellar/stellar.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { StellarService } from './stellar.service'; + +@Module({ + providers: [StellarService], + exports: [StellarService], +}) +export class StellarModule {} diff --git a/src/stellar/stellar.service.ts b/src/stellar/stellar.service.ts new file mode 100644 index 0000000..502166f --- /dev/null +++ b/src/stellar/stellar.service.ts @@ -0,0 +1,336 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + Address, + Contract, + Keypair, + nativeToScVal, + scValToNative, + TransactionBuilder, + rpc, + BASE_FEE, + Networks, + xdr, +} from '@stellar/stellar-sdk'; + +const NETWORK_PASSPHRASES: Record = { + testnet: Networks.TESTNET, + futurenet: Networks.FUTURENET, + mainnet: Networks.PUBLIC, +}; + +export interface OnChainTicket { + eventId: bigint; + owner: string; + tier: string; + seat: string; + status: 'Valid' | 'Used' | 'Revoked' | 'Resale'; + originalPrice: bigint; + resalePrice: bigint; +} + +export interface OnChainEvent { + organizer: string; + name: string; + category: string; + maxResaleMultiplierBps: number; + royaltyBps: number; + ticketsIssued: bigint; +} + +/** + * Wrapper around the `ticketing` Soroban contract. This service never holds + * a user's Stellar secret key — the platform is non-custodial by design. + * Every write is a two-step "build then submit" flow: + * + * 1. `build*Tx(...)` simulates the call against the user's own public key + * as the source account and returns an unsigned, fee-prepared XDR + * envelope for the caller's wallet (Freighter, etc.) to sign. + * 2. `submitSignedTransaction(signedXdr)` relays the wallet-signed + * envelope to the network and polls it through to completion. + * + * `PLATFORM_SIGNER` is used only as a disposable source account for + * read-only simulations (`verifyTicket`, `getEvent`) — those contract + * functions never call `require_auth`, so no signature from it is ever + * required or requested. + */ +@Injectable() +export class StellarService { + private readonly logger = new Logger(StellarService.name); + private readonly server: rpc.Server; + private readonly contract: Contract; + private readonly networkPassphrase: string; + private readonly platformSigner: Keypair; + + constructor(private readonly config: ConfigService) { + const rpcUrl = this.config.getOrThrow('SOROBAN_RPC_URL'); + const network = this.config.getOrThrow('STELLAR_NETWORK'); + this.server = new rpc.Server(rpcUrl, { + allowHttp: rpcUrl.startsWith('http://'), + }); + this.contract = new Contract( + this.config.getOrThrow('TICKETING_CONTRACT_ID'), + ); + this.networkPassphrase = NETWORK_PASSPHRASES[network]; + this.platformSigner = Keypair.fromSecret( + this.config.getOrThrow('PLATFORM_SIGNER_SECRET'), + ); + } + + /** Read-only simulation — no signature, no ledger write, no fee. */ + async verifyTicket(chainTicketId: bigint): Promise { + const result = await this.simulateRead('verify_ticket', [ + nativeToScVal(chainTicketId, { type: 'u64' }), + ]); + return this.decodeTicket(result); + } + + async getEvent(chainEventId: bigint): Promise { + const result = await this.simulateRead('get_event', [ + nativeToScVal(chainEventId, { type: 'u64' }), + ]); + return this.decodeEvent(result); + } + + buildCreateEventTx(params: { + organizerPublicKey: string; + chainEventId: bigint; + name: string; + category: string; + maxResaleMultiplierBps: number; + royaltyBps: number; + }): Promise { + return this.buildTx(params.organizerPublicKey, 'create_event', [ + new Address(params.organizerPublicKey).toScVal(), + nativeToScVal(params.chainEventId, { type: 'u64' }), + nativeToScVal(params.name, { type: 'string' }), + nativeToScVal(params.category, { type: 'string' }), + nativeToScVal(params.maxResaleMultiplierBps, { type: 'u32' }), + nativeToScVal(params.royaltyBps, { type: 'u32' }), + ]); + } + + buildIssueTicketTx(params: { + organizerPublicKey: string; + chainEventId: bigint; + toPublicKey: string; + tier: string; + seat: string; + price: bigint; + }): Promise { + return this.buildTx(params.organizerPublicKey, 'issue_ticket', [ + new Address(params.organizerPublicKey).toScVal(), + nativeToScVal(params.chainEventId, { type: 'u64' }), + new Address(params.toPublicKey).toScVal(), + nativeToScVal(params.tier, { type: 'string' }), + nativeToScVal(params.seat, { type: 'string' }), + nativeToScVal(params.price, { type: 'i128' }), + ]); + } + + buildPurchasePrimaryTx(params: { + buyerPublicKey: string; + chainEventId: bigint; + tier: string; + seat: string; + price: bigint; + }): Promise { + return this.buildTx(params.buyerPublicKey, 'purchase_primary', [ + new Address(params.buyerPublicKey).toScVal(), + nativeToScVal(params.chainEventId, { type: 'u64' }), + nativeToScVal(params.tier, { type: 'string' }), + nativeToScVal(params.seat, { type: 'string' }), + nativeToScVal(params.price, { type: 'i128' }), + ]); + } + + buildTransferTicketTx(params: { + fromPublicKey: string; + chainTicketId: bigint; + toPublicKey: string; + }): Promise { + return this.buildTx(params.fromPublicKey, 'transfer_ticket', [ + new Address(params.fromPublicKey).toScVal(), + nativeToScVal(params.chainTicketId, { type: 'u64' }), + new Address(params.toPublicKey).toScVal(), + ]); + } + + buildCheckInTx(params: { + organizerPublicKey: string; + chainTicketId: bigint; + }): Promise { + return this.buildTx(params.organizerPublicKey, 'check_in', [ + new Address(params.organizerPublicKey).toScVal(), + nativeToScVal(params.chainTicketId, { type: 'u64' }), + ]); + } + + buildRevokeTicketTx(params: { + organizerPublicKey: string; + chainTicketId: bigint; + }): Promise { + return this.buildTx(params.organizerPublicKey, 'revoke_ticket', [ + new Address(params.organizerPublicKey).toScVal(), + nativeToScVal(params.chainTicketId, { type: 'u64' }), + ]); + } + + buildListForResaleTx(params: { + ownerPublicKey: string; + chainTicketId: bigint; + price: bigint; + }): Promise { + return this.buildTx(params.ownerPublicKey, 'list_for_resale', [ + new Address(params.ownerPublicKey).toScVal(), + nativeToScVal(params.chainTicketId, { type: 'u64' }), + nativeToScVal(params.price, { type: 'i128' }), + ]); + } + + buildCancelResaleTx(params: { + ownerPublicKey: string; + chainTicketId: bigint; + }): Promise { + return this.buildTx(params.ownerPublicKey, 'cancel_resale', [ + new Address(params.ownerPublicKey).toScVal(), + nativeToScVal(params.chainTicketId, { type: 'u64' }), + ]); + } + + buildBuyResaleTx(params: { + buyerPublicKey: string; + chainTicketId: bigint; + }): Promise { + return this.buildTx(params.buyerPublicKey, 'buy_resale', [ + new Address(params.buyerPublicKey).toScVal(), + nativeToScVal(params.chainTicketId, { type: 'u64' }), + ]); + } + + /** + * Submits a wallet-signed XDR envelope (produced from one of the `build*Tx` + * methods above and signed client-side) and polls it through to a result. + */ + async submitSignedTransaction( + signedXdr: string, + ): Promise<{ result: unknown; txHash: string }> { + const tx = TransactionBuilder.fromXDR(signedXdr, this.networkPassphrase); + const sendResult = await this.server.sendTransaction(tx); + if (sendResult.status === 'ERROR') { + throw new Error( + `Soroban submission failed: ${JSON.stringify(sendResult.errorResult)}`, + ); + } + + const txHash = sendResult.hash; + const returnValue = await this.pollTransaction(txHash); + return { result: scValToNative(returnValue), txHash }; + } + + private async simulateRead(fn: string, args: xdr.ScVal[]) { + const account = await this.server.getAccount( + this.platformSigner.publicKey(), + ); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(this.contract.call(fn, ...args)) + .setTimeout(30) + .build(); + + const sim = await this.server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) { + throw new Error(`Soroban simulation failed for ${fn}: ${sim.error}`); + } + if (!sim.result) { + throw new Error(`Soroban simulation for ${fn} returned no result`); + } + return sim.result.retval; + } + + /** Simulates + assigns fees/footprint for `fn`, returning an unsigned XDR envelope. */ + private async buildTx( + sourcePublicKey: string, + fn: string, + args: xdr.ScVal[], + ): Promise { + const account = await this.server.getAccount(sourcePublicKey); + const built = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(this.contract.call(fn, ...args)) + .setTimeout(300) + .build(); + + const prepared = await this.server.prepareTransaction(built); + return prepared.toXDR(); + } + + private async pollTransaction( + hash: string, + attempts = 15, + ): Promise { + for (let i = 0; i < attempts; i++) { + const tx = await this.server.getTransaction(hash); + if (tx.status === rpc.Api.GetTransactionStatus.SUCCESS) { + if (!tx.returnValue) { + throw new Error( + `Transaction ${hash} succeeded without a return value`, + ); + } + return tx.returnValue; + } + if (tx.status === rpc.Api.GetTransactionStatus.FAILED) { + throw new Error(`Transaction ${hash} failed on-chain`); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error(`Timed out waiting for transaction ${hash} to land`); + } + + private decodeTicket(scVal: xdr.ScVal): OnChainTicket { + const native = scValToNative(scVal) as Record; + return { + eventId: native.event_id as bigint, + owner: native.owner as string, + tier: native.tier as string, + seat: native.seat as string, + status: this.decodeStatus(native.status), + originalPrice: native.original_price as bigint, + resalePrice: native.resale_price as bigint, + }; + } + + private decodeEvent(scVal: xdr.ScVal): OnChainEvent { + const native = scValToNative(scVal) as Record; + return { + organizer: native.organizer as string, + name: native.name as string, + category: native.category as string, + maxResaleMultiplierBps: native.max_resale_multiplier_bps as number, + royaltyBps: native.royalty_bps as number, + ticketsIssued: native.tickets_issued as bigint, + }; + } + + private decodeStatus(raw: unknown): OnChainTicket['status'] { + // soroban_sdk unit-variant enums decode as either a bare tag string or + // `{ tag: string }` depending on SDK version — handle both. + const tag = typeof raw === 'string' ? raw : (raw as { tag: string })?.tag; + if ( + tag === 'Valid' || + tag === 'Used' || + tag === 'Revoked' || + tag === 'Resale' + ) { + return tag; + } + this.logger.warn( + `Unrecognized on-chain ticket status: ${JSON.stringify(raw)}`, + ); + return 'Valid'; + } +} diff --git a/src/tickets/dto/confirm-issue-ticket.dto.spec.ts b/src/tickets/dto/confirm-issue-ticket.dto.spec.ts new file mode 100644 index 0000000..de70a40 --- /dev/null +++ b/src/tickets/dto/confirm-issue-ticket.dto.spec.ts @@ -0,0 +1,25 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { ConfirmIssueTicketDto } from './confirm-issue-ticket.dto'; + +const UUID = '11111111-1111-4111-8111-111111111111'; + +describe('ConfirmIssueTicketDto', () => { + it('accepts a well-formed payload', async () => { + const dto = plainToInstance(ConfirmIssueTicketDto, { + ticketTypeId: UUID, + toUserId: UUID, + signedXdr: 'AAAAAgAAAAA=', + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('rejects a payload missing signedXdr', async () => { + const dto = plainToInstance(ConfirmIssueTicketDto, { + ticketTypeId: UUID, + toUserId: UUID, + }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'signedXdr')).toBe(true); + }); +}); diff --git a/src/tickets/dto/confirm-issue-ticket.dto.ts b/src/tickets/dto/confirm-issue-ticket.dto.ts new file mode 100644 index 0000000..5234bdd --- /dev/null +++ b/src/tickets/dto/confirm-issue-ticket.dto.ts @@ -0,0 +1,14 @@ +import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { ConfirmSignedTxDto } from './confirm-signed-tx.dto'; + +export class ConfirmIssueTicketDto extends ConfirmSignedTxDto { + @IsUUID() + ticketTypeId: string; + + @IsUUID() + toUserId: string; + + @IsOptional() + @IsString() + seat?: string; +} diff --git a/src/tickets/dto/confirm-list-for-resale.dto.ts b/src/tickets/dto/confirm-list-for-resale.dto.ts new file mode 100644 index 0000000..54c32cd --- /dev/null +++ b/src/tickets/dto/confirm-list-for-resale.dto.ts @@ -0,0 +1,7 @@ +import { IsString } from 'class-validator'; +import { ConfirmSignedTxDto } from './confirm-signed-tx.dto'; + +export class ConfirmListForResaleDto extends ConfirmSignedTxDto { + @IsString() + price: string; +} diff --git a/src/tickets/dto/confirm-purchase-primary.dto.ts b/src/tickets/dto/confirm-purchase-primary.dto.ts new file mode 100644 index 0000000..99df822 --- /dev/null +++ b/src/tickets/dto/confirm-purchase-primary.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { ConfirmSignedTxDto } from './confirm-signed-tx.dto'; + +export class ConfirmPurchasePrimaryDto extends ConfirmSignedTxDto { + @IsUUID() + ticketTypeId: string; + + @IsOptional() + @IsString() + seat?: string; +} diff --git a/src/tickets/dto/confirm-signed-tx.dto.spec.ts b/src/tickets/dto/confirm-signed-tx.dto.spec.ts new file mode 100644 index 0000000..bceaa28 --- /dev/null +++ b/src/tickets/dto/confirm-signed-tx.dto.spec.ts @@ -0,0 +1,18 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { ConfirmSignedTxDto } from './confirm-signed-tx.dto'; + +describe('ConfirmSignedTxDto', () => { + it('accepts a string signedXdr', async () => { + const dto = plainToInstance(ConfirmSignedTxDto, { + signedXdr: 'AAAAAgAAAAA=', + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('rejects a missing signedXdr', async () => { + const dto = plainToInstance(ConfirmSignedTxDto, {}); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'signedXdr')).toBe(true); + }); +}); diff --git a/src/tickets/dto/confirm-signed-tx.dto.ts b/src/tickets/dto/confirm-signed-tx.dto.ts new file mode 100644 index 0000000..59aa03d --- /dev/null +++ b/src/tickets/dto/confirm-signed-tx.dto.ts @@ -0,0 +1,7 @@ +import { IsString } from 'class-validator'; + +/** Base shape for every "confirm" endpoint that relays a wallet-signed XDR envelope. */ +export class ConfirmSignedTxDto { + @IsString() + signedXdr: string; +} diff --git a/src/tickets/dto/confirm-transfer-ticket.dto.ts b/src/tickets/dto/confirm-transfer-ticket.dto.ts new file mode 100644 index 0000000..88158c1 --- /dev/null +++ b/src/tickets/dto/confirm-transfer-ticket.dto.ts @@ -0,0 +1,7 @@ +import { IsUUID } from 'class-validator'; +import { ConfirmSignedTxDto } from './confirm-signed-tx.dto'; + +export class ConfirmTransferTicketDto extends ConfirmSignedTxDto { + @IsUUID() + toUserId: string; +} diff --git a/src/tickets/dto/issue-ticket.dto.spec.ts b/src/tickets/dto/issue-ticket.dto.spec.ts new file mode 100644 index 0000000..524d32e --- /dev/null +++ b/src/tickets/dto/issue-ticket.dto.spec.ts @@ -0,0 +1,42 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { IssueTicketDto } from './issue-ticket.dto'; + +const UUID = '11111111-1111-4111-8111-111111111111'; + +describe('IssueTicketDto', () => { + it('accepts a payload without a seat', async () => { + const dto = plainToInstance(IssueTicketDto, { + ticketTypeId: UUID, + toUserId: UUID, + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('accepts a payload with a seat', async () => { + const dto = plainToInstance(IssueTicketDto, { + ticketTypeId: UUID, + toUserId: UUID, + seat: 'A1', + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('rejects a non-UUID ticketTypeId', async () => { + const dto = plainToInstance(IssueTicketDto, { + ticketTypeId: 'not-a-uuid', + toUserId: UUID, + }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'ticketTypeId')).toBe(true); + }); + + it('rejects a non-UUID toUserId', async () => { + const dto = plainToInstance(IssueTicketDto, { + ticketTypeId: UUID, + toUserId: 'nope', + }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'toUserId')).toBe(true); + }); +}); diff --git a/src/tickets/dto/issue-ticket.dto.ts b/src/tickets/dto/issue-ticket.dto.ts new file mode 100644 index 0000000..0f14dfe --- /dev/null +++ b/src/tickets/dto/issue-ticket.dto.ts @@ -0,0 +1,13 @@ +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class IssueTicketDto { + @IsUUID() + ticketTypeId: string; + + @IsUUID() + toUserId: string; + + @IsOptional() + @IsString() + seat?: string; +} diff --git a/src/tickets/dto/list-for-resale.dto.spec.ts b/src/tickets/dto/list-for-resale.dto.spec.ts new file mode 100644 index 0000000..d12390e --- /dev/null +++ b/src/tickets/dto/list-for-resale.dto.spec.ts @@ -0,0 +1,16 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { ListForResaleDto } from './list-for-resale.dto'; + +describe('ListForResaleDto', () => { + it('accepts a numeric string price', async () => { + const dto = plainToInstance(ListForResaleDto, { price: '1200' }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('rejects a non-string price', async () => { + const dto = plainToInstance(ListForResaleDto, { price: 1200 }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'price')).toBe(true); + }); +}); diff --git a/src/tickets/dto/list-for-resale.dto.ts b/src/tickets/dto/list-for-resale.dto.ts new file mode 100644 index 0000000..e7358ac --- /dev/null +++ b/src/tickets/dto/list-for-resale.dto.ts @@ -0,0 +1,7 @@ +import { IsString } from 'class-validator'; + +export class ListForResaleDto { + /** Asking price in the settlement token's smallest unit, as a string to preserve i128 precision. */ + @IsString() + price: string; +} diff --git a/src/tickets/dto/purchase-primary.dto.spec.ts b/src/tickets/dto/purchase-primary.dto.spec.ts new file mode 100644 index 0000000..4617117 --- /dev/null +++ b/src/tickets/dto/purchase-primary.dto.spec.ts @@ -0,0 +1,26 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { PurchasePrimaryDto } from './purchase-primary.dto'; + +const UUID = '11111111-1111-4111-8111-111111111111'; + +describe('PurchasePrimaryDto', () => { + it('accepts a payload without a seat', async () => { + const dto = plainToInstance(PurchasePrimaryDto, { ticketTypeId: UUID }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('accepts a payload with a seat', async () => { + const dto = plainToInstance(PurchasePrimaryDto, { + ticketTypeId: UUID, + seat: 'B12', + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('rejects a non-UUID ticketTypeId', async () => { + const dto = plainToInstance(PurchasePrimaryDto, { ticketTypeId: 'nope' }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'ticketTypeId')).toBe(true); + }); +}); diff --git a/src/tickets/dto/purchase-primary.dto.ts b/src/tickets/dto/purchase-primary.dto.ts new file mode 100644 index 0000000..7ecda7d --- /dev/null +++ b/src/tickets/dto/purchase-primary.dto.ts @@ -0,0 +1,10 @@ +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class PurchasePrimaryDto { + @IsUUID() + ticketTypeId: string; + + @IsOptional() + @IsString() + seat?: string; +} diff --git a/src/tickets/dto/transfer-ticket.dto.spec.ts b/src/tickets/dto/transfer-ticket.dto.spec.ts new file mode 100644 index 0000000..c709b1d --- /dev/null +++ b/src/tickets/dto/transfer-ticket.dto.spec.ts @@ -0,0 +1,24 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { TransferTicketDto } from './transfer-ticket.dto'; + +const UUID = '11111111-1111-4111-8111-111111111111'; + +describe('TransferTicketDto', () => { + it('accepts a valid UUID', async () => { + const dto = plainToInstance(TransferTicketDto, { toUserId: UUID }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('rejects a non-UUID toUserId', async () => { + const dto = plainToInstance(TransferTicketDto, { toUserId: 'nope' }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'toUserId')).toBe(true); + }); + + it('rejects a missing toUserId', async () => { + const dto = plainToInstance(TransferTicketDto, {}); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'toUserId')).toBe(true); + }); +}); diff --git a/src/tickets/dto/transfer-ticket.dto.ts b/src/tickets/dto/transfer-ticket.dto.ts new file mode 100644 index 0000000..a97a046 --- /dev/null +++ b/src/tickets/dto/transfer-ticket.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class TransferTicketDto { + @IsUUID() + toUserId: string; +} diff --git a/src/tickets/tickets.controller.ts b/src/tickets/tickets.controller.ts new file mode 100644 index 0000000..d35f677 --- /dev/null +++ b/src/tickets/tickets.controller.ts @@ -0,0 +1,228 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { CurrentUserPayload } from '../auth/decorators/current-user.decorator'; +import { TicketsService } from './tickets.service'; +import { IssueTicketDto } from './dto/issue-ticket.dto'; +import { ConfirmIssueTicketDto } from './dto/confirm-issue-ticket.dto'; +import { PurchasePrimaryDto } from './dto/purchase-primary.dto'; +import { ConfirmPurchasePrimaryDto } from './dto/confirm-purchase-primary.dto'; +import { TransferTicketDto } from './dto/transfer-ticket.dto'; +import { ConfirmTransferTicketDto } from './dto/confirm-transfer-ticket.dto'; +import { ConfirmSignedTxDto } from './dto/confirm-signed-tx.dto'; +import { ListForResaleDto } from './dto/list-for-resale.dto'; +import { ConfirmListForResaleDto } from './dto/confirm-list-for-resale.dto'; + +@Controller('tickets') +@UseGuards(JwtAuthGuard) +export class TicketsController { + constructor(private readonly ticketsService: TicketsService) {} + + @Get('resale') + findActiveResaleListings() { + return this.ticketsService.findActiveResaleListings(); + } + + @Get('mine') + findMine(@CurrentUser() user: CurrentUserPayload) { + return this.ticketsService.findMine(user.userId); + } + + @Get('verify/:qrSecret') + verify( + @CurrentUser() user: CurrentUserPayload, + @Param('qrSecret') qrSecret: string, + ) { + return this.ticketsService.verify(user.userId, qrSecret); + } + + @Post('issue') + buildIssueTx( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: IssueTicketDto, + ) { + return this.ticketsService.buildIssueTx( + user.userId, + dto.ticketTypeId, + dto.toUserId, + dto.seat, + ); + } + + @Post('confirm-issue') + confirmIssue( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: ConfirmIssueTicketDto, + ) { + return this.ticketsService.confirmIssue( + user.userId, + dto.ticketTypeId, + dto.toUserId, + dto.seat, + dto.signedXdr, + ); + } + + @Post('purchase') + buildPurchaseTx( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: PurchasePrimaryDto, + ) { + return this.ticketsService.buildPurchaseTx( + user.userId, + dto.ticketTypeId, + dto.seat, + ); + } + + @Post('confirm-purchase') + confirmPurchase( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: ConfirmPurchasePrimaryDto, + ) { + return this.ticketsService.confirmPurchase( + user.userId, + dto.ticketTypeId, + dto.seat, + dto.signedXdr, + ); + } + + @Post(':ticketId/transfer') + buildTransferTx( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + @Body() dto: TransferTicketDto, + ) { + return this.ticketsService.buildTransferTx( + user.userId, + ticketId, + dto.toUserId, + ); + } + + @Post(':ticketId/confirm-transfer') + confirmTransfer( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + @Body() dto: ConfirmTransferTicketDto, + ) { + return this.ticketsService.confirmTransfer( + user.userId, + ticketId, + dto.toUserId, + dto.signedXdr, + ); + } + + @Post(':ticketId/check-in') + buildCheckInTx( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + ) { + return this.ticketsService.buildCheckInTx(user.userId, ticketId); + } + + @Post(':ticketId/confirm-check-in') + confirmCheckIn( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + @Body() dto: ConfirmSignedTxDto, + ) { + return this.ticketsService.confirmCheckIn( + user.userId, + ticketId, + dto.signedXdr, + ); + } + + @Post(':ticketId/revoke') + buildRevokeTx( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + ) { + return this.ticketsService.buildRevokeTx(user.userId, ticketId); + } + + @Post(':ticketId/confirm-revoke') + confirmRevoke( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + @Body() dto: ConfirmSignedTxDto, + ) { + return this.ticketsService.confirmRevoke( + user.userId, + ticketId, + dto.signedXdr, + ); + } + + @Post(':ticketId/list-resale') + buildListForResaleTx( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + @Body() dto: ListForResaleDto, + ) { + return this.ticketsService.buildListForResaleTx( + user.userId, + ticketId, + dto.price, + ); + } + + @Post(':ticketId/confirm-list-resale') + confirmListForResale( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + @Body() dto: ConfirmListForResaleDto, + ) { + return this.ticketsService.confirmListForResale( + user.userId, + ticketId, + dto.price, + dto.signedXdr, + ); + } + + @Post(':ticketId/cancel-resale') + buildCancelResaleTx( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + ) { + return this.ticketsService.buildCancelResaleTx(user.userId, ticketId); + } + + @Post(':ticketId/confirm-cancel-resale') + confirmCancelResale( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + @Body() dto: ConfirmSignedTxDto, + ) { + return this.ticketsService.confirmCancelResale( + user.userId, + ticketId, + dto.signedXdr, + ); + } + + @Post(':ticketId/buy-resale') + buildBuyResaleTx( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + ) { + return this.ticketsService.buildBuyResaleTx(user.userId, ticketId); + } + + @Post(':ticketId/confirm-buy-resale') + confirmBuyResale( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + @Body() dto: ConfirmSignedTxDto, + ) { + return this.ticketsService.confirmBuyResale( + user.userId, + ticketId, + dto.signedXdr, + ); + } +} diff --git a/src/tickets/tickets.module.ts b/src/tickets/tickets.module.ts new file mode 100644 index 0000000..c3849f7 --- /dev/null +++ b/src/tickets/tickets.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { OrganizationsModule } from '../organizations/organizations.module'; +import { StellarModule } from '../stellar/stellar.module'; +import { TicketsController } from './tickets.controller'; +import { TicketsService } from './tickets.service'; + +@Module({ + imports: [OrganizationsModule, StellarModule], + controllers: [TicketsController], + providers: [TicketsService], +}) +export class TicketsModule {} diff --git a/src/tickets/tickets.service.spec.ts b/src/tickets/tickets.service.spec.ts new file mode 100644 index 0000000..d043e34 --- /dev/null +++ b/src/tickets/tickets.service.spec.ts @@ -0,0 +1,335 @@ +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; + +// TicketsService only needs StellarService's shape here (it's fully mocked +// below); avoid touching the real @stellar/stellar-sdk import chain, which +// ships ESM-only transitive deps (@noble/hashes, uint8array-extras) that +// Jest can't parse without a much heavier transform config. +jest.mock('../stellar/stellar.service', () => ({ StellarService: jest.fn() })); + +import { TicketsService } from './tickets.service'; +import type { PrismaService } from '../prisma/prisma.service'; +import type { OrganizationsService } from '../organizations/organizations.service'; +import type { StellarService } from '../stellar/stellar.service'; + +function buildTicketType(overrides: Partial> = {}) { + return { + id: 'tt-1', + name: 'GA', + price: 1_000n, + quantityIssued: 0, + quantityTotal: 100, + event: { + id: 'event-1', + organizationId: 'org-1', + chainEventId: 42n, + organization: { stellarAccount: 'GORGANIZER' }, + }, + ...overrides, + }; +} + +describe('TicketsService', () => { + let service: TicketsService; + let prisma: { + ticketType: { findUnique: jest.Mock; update: jest.Mock }; + ticket: { + findUnique: jest.Mock; + update: jest.Mock; + create: jest.Mock; + findMany: jest.Mock; + }; + resaleListing: { + create: jest.Mock; + updateMany: jest.Mock; + findMany: jest.Mock; + }; + user: { findUnique: jest.Mock }; + $transaction: jest.Mock; + }; + let organizations: { assertMember: jest.Mock }; + let stellar: Record; + + beforeEach(() => { + prisma = { + ticketType: { findUnique: jest.fn(), update: jest.fn() }, + ticket: { + findUnique: jest.fn(), + update: jest.fn(), + create: jest.fn(), + findMany: jest.fn(), + }, + resaleListing: { + create: jest.fn(), + updateMany: jest.fn(), + findMany: jest.fn(), + }, + user: { findUnique: jest.fn() }, + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(prisma)), + }; + organizations = { assertMember: jest.fn().mockResolvedValue(undefined) }; + stellar = { + buildIssueTicketTx: jest.fn().mockResolvedValue('unsigned-xdr'), + buildPurchasePrimaryTx: jest.fn().mockResolvedValue('unsigned-xdr'), + buildTransferTicketTx: jest.fn().mockResolvedValue('unsigned-xdr'), + buildCheckInTx: jest.fn().mockResolvedValue('unsigned-xdr'), + buildRevokeTicketTx: jest.fn().mockResolvedValue('unsigned-xdr'), + buildListForResaleTx: jest.fn().mockResolvedValue('unsigned-xdr'), + buildCancelResaleTx: jest.fn().mockResolvedValue('unsigned-xdr'), + buildBuyResaleTx: jest.fn().mockResolvedValue('unsigned-xdr'), + submitSignedTransaction: jest + .fn() + .mockResolvedValue({ result: 7n, txHash: '0xabc' }), + verifyTicket: jest.fn(), + }; + + service = new TicketsService( + prisma as unknown as PrismaService, + organizations as unknown as OrganizationsService, + stellar as unknown as StellarService, + ); + }); + + describe('buildIssueTx', () => { + it('rejects once a ticket type is sold out', async () => { + prisma.ticketType.findUnique.mockResolvedValue( + buildTicketType({ quantityIssued: 100, quantityTotal: 100 }), + ); + + await expect( + service.buildIssueTx('organizer-1', 'tt-1', 'buyer-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(stellar.buildIssueTicketTx).not.toHaveBeenCalled(); + }); + + it('rejects issuing against an unpublished event', async () => { + prisma.ticketType.findUnique.mockResolvedValue( + buildTicketType({ + event: { ...buildTicketType().event, chainEventId: null }, + }), + ); + + await expect( + service.buildIssueTx('organizer-1', 'tt-1', 'buyer-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('requires the recipient to have a connected wallet', async () => { + prisma.ticketType.findUnique.mockResolvedValue(buildTicketType()); + prisma.user.findUnique.mockResolvedValue({ + id: 'buyer-1', + stellarPublicKey: null, + }); + + await expect( + service.buildIssueTx('organizer-1', 'tt-1', 'buyer-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('builds an issue_ticket transaction against the organizer account', async () => { + prisma.ticketType.findUnique.mockResolvedValue(buildTicketType()); + prisma.user.findUnique.mockResolvedValue({ + id: 'buyer-1', + stellarPublicKey: 'GBUYER', + }); + + const { unsignedXdr } = await service.buildIssueTx( + 'organizer-1', + 'tt-1', + 'buyer-1', + 'A1', + ); + + expect(unsignedXdr).toBe('unsigned-xdr'); + expect(organizations.assertMember).toHaveBeenCalledWith( + 'org-1', + 'organizer-1', + ); + expect(stellar.buildIssueTicketTx).toHaveBeenCalledWith({ + organizerPublicKey: 'GORGANIZER', + chainEventId: 42n, + toPublicKey: 'GBUYER', + tier: 'GA', + seat: 'A1', + price: 1_000n, + }); + }); + }); + + describe('confirmIssue', () => { + it('creates the ticket row and increments quantityIssued using the on-chain ticket id', async () => { + prisma.ticketType.findUnique.mockResolvedValue(buildTicketType()); + prisma.ticket.create.mockImplementation(({ data }) => + Promise.resolve({ id: 'ticket-1', ...data }), + ); + + const ticket = await service.confirmIssue( + 'organizer-1', + 'tt-1', + 'buyer-1', + 'A1', + 'signed-xdr', + ); + + expect(stellar.submitSignedTransaction).toHaveBeenCalledWith( + 'signed-xdr', + ); + expect(prisma.ticketType.update).toHaveBeenCalledWith({ + where: { id: 'tt-1' }, + data: { quantityIssued: { increment: 1 } }, + }); + expect(ticket).toMatchObject({ + chainTicketId: 7n, + ownerId: 'buyer-1', + seat: 'A1', + }); + }); + }); + + describe('transfer', () => { + it('refuses to build a transfer for a ticket the caller does not own', async () => { + prisma.ticket.findUnique.mockResolvedValue({ + id: 'ticket-1', + ownerId: 'someone-else', + chainTicketId: 7n, + event: { + organizationId: 'org-1', + organization: { stellarAccount: 'GORG' }, + }, + }); + + await expect( + service.buildTransferTx('not-the-owner', 'ticket-1', 'friend-1'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('builds a transfer using both parties on-chain public keys', async () => { + prisma.ticket.findUnique.mockResolvedValue({ + id: 'ticket-1', + ownerId: 'owner-1', + chainTicketId: 7n, + event: { + organizationId: 'org-1', + organization: { stellarAccount: 'GORG' }, + }, + }); + prisma.user.findUnique + .mockResolvedValueOnce({ id: 'owner-1', stellarPublicKey: 'GOWNER' }) + .mockResolvedValueOnce({ id: 'friend-1', stellarPublicKey: 'GFRIEND' }); + + await service.buildTransferTx('owner-1', 'ticket-1', 'friend-1'); + + expect(stellar.buildTransferTicketTx).toHaveBeenCalledWith({ + fromPublicKey: 'GOWNER', + chainTicketId: 7n, + toPublicKey: 'GFRIEND', + }); + }); + }); + + describe('verify', () => { + it('reconciles the cached status when it diverges from the chain', async () => { + prisma.ticket.findUnique.mockResolvedValue({ + id: 'ticket-1', + chainTicketId: 7n, + status: 'VALID', + seat: 'A1', + event: { organizationId: 'org-1', name: 'Radiohead Live' }, + owner: { name: 'Ada Lovelace' }, + ticketType: { name: 'GA' }, + }); + stellar.verifyTicket.mockResolvedValue({ + owner: 'GBUYER', + status: 'Used', + }); + + const result = await service.verify('staff-1', 'qr-secret-abc'); + + expect(prisma.ticket.update).toHaveBeenCalledWith({ + where: { id: 'ticket-1' }, + data: { status: 'USED' }, + }); + expect(result.status).toBe('USED'); + expect(result.eventName).toBe('Radiohead Live'); + }); + + it('throws when no ticket matches the scanned secret', async () => { + prisma.ticket.findUnique.mockResolvedValue(null); + + await expect( + service.verify('staff-1', 'unknown-secret'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe('resale marketplace', () => { + it('rejects buying a ticket that is not listed for resale', async () => { + prisma.ticket.findUnique.mockResolvedValue({ + id: 'ticket-1', + status: 'VALID', + chainTicketId: 7n, + event: { + organizationId: 'org-1', + organization: { stellarAccount: 'GORG' }, + }, + }); + + await expect( + service.buildBuyResaleTx('buyer-1', 'ticket-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('creates an active resale listing on confirm', async () => { + prisma.ticket.findUnique.mockResolvedValue({ + id: 'ticket-1', + ownerId: 'owner-1', + chainTicketId: 7n, + event: { + organizationId: 'org-1', + organization: { stellarAccount: 'GORG' }, + }, + }); + prisma.resaleListing.create.mockResolvedValue({ + id: 'listing-1', + status: 'ACTIVE', + }); + + await service.confirmListForResale( + 'owner-1', + 'ticket-1', + '1200', + 'signed-xdr', + ); + + expect(prisma.ticket.update).toHaveBeenCalledWith({ + where: { id: 'ticket-1' }, + data: { status: 'RESALE' }, + }); + expect(prisma.resaleListing.create).toHaveBeenCalledWith({ + data: { + ticketId: 'ticket-1', + sellerId: 'owner-1', + price: 1200n, + txHash: '0xabc', + }, + }); + }); + }); + + describe('findMine', () => { + it('scopes the query to the caller’s own tickets', async () => { + prisma.ticket.findMany.mockResolvedValue([ + { id: 'ticket-1', ownerId: 'owner-1' }, + ]); + + await service.findMine('owner-1'); + + expect(prisma.ticket.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { ownerId: 'owner-1' } }), + ); + }); + }); +}); diff --git a/src/tickets/tickets.service.ts b/src/tickets/tickets.service.ts new file mode 100644 index 0000000..3607d9c --- /dev/null +++ b/src/tickets/tickets.service.ts @@ -0,0 +1,408 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ResaleListingStatus, TicketStatus } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { OrganizationsService } from '../organizations/organizations.service'; +import { StellarService } from '../stellar/stellar.service'; + +@Injectable() +export class TicketsService { + constructor( + private readonly prisma: PrismaService, + private readonly organizations: OrganizationsService, + private readonly stellar: StellarService, + ) {} + + // ---- Organizer-authorized issuance (off-chain payment already settled) ---- + + async buildIssueTx( + userId: string, + ticketTypeId: string, + toUserId: string, + seat?: string, + ) { + const { ticketType, event } = + await this.getTicketTypeWithEvent(ticketTypeId); + await this.organizations.assertMember(event.organizationId, userId); + this.assertHasCapacity(ticketType.quantityIssued, ticketType.quantityTotal); + if (event.chainEventId === null) { + throw new BadRequestException( + 'Event has not been published on-chain yet', + ); + } + + const toUser = await this.getUserWithWallet(toUserId); + const unsignedXdr = await this.stellar.buildIssueTicketTx({ + organizerPublicKey: event.organization.stellarAccount, + chainEventId: event.chainEventId, + toPublicKey: toUser.stellarPublicKey!, + tier: ticketType.name, + seat: seat ?? 'unassigned', + price: ticketType.price, + }); + return { unsignedXdr }; + } + + async confirmIssue( + userId: string, + ticketTypeId: string, + toUserId: string, + seat: string | undefined, + signedXdr: string, + ) { + const { event } = await this.getTicketTypeWithEvent(ticketTypeId); + await this.organizations.assertMember(event.organizationId, userId); + + const { result, txHash } = + await this.stellar.submitSignedTransaction(signedXdr); + const chainTicketId = result as bigint; + + return this.prisma.$transaction(async (tx) => { + await tx.ticketType.update({ + where: { id: ticketTypeId }, + data: { quantityIssued: { increment: 1 } }, + }); + return tx.ticket.create({ + data: { + eventId: event.id, + ticketTypeId, + ownerId: toUserId, + chainTicketId, + seat: seat ?? 'unassigned', + issuedTxHash: txHash, + }, + }); + }); + } + + // ---- Fully on-chain primary sale ---- + + async buildPurchaseTx(buyerId: string, ticketTypeId: string, seat?: string) { + const { ticketType, event } = + await this.getTicketTypeWithEvent(ticketTypeId); + this.assertHasCapacity(ticketType.quantityIssued, ticketType.quantityTotal); + if (event.chainEventId === null) { + throw new BadRequestException( + 'Event has not been published on-chain yet', + ); + } + const buyer = await this.getUserWithWallet(buyerId); + + const unsignedXdr = await this.stellar.buildPurchasePrimaryTx({ + buyerPublicKey: buyer.stellarPublicKey!, + chainEventId: event.chainEventId, + tier: ticketType.name, + seat: seat ?? 'unassigned', + price: ticketType.price, + }); + return { unsignedXdr }; + } + + async confirmPurchase( + buyerId: string, + ticketTypeId: string, + seat: string | undefined, + signedXdr: string, + ) { + const { event } = await this.getTicketTypeWithEvent(ticketTypeId); + const { result, txHash } = + await this.stellar.submitSignedTransaction(signedXdr); + const chainTicketId = result as bigint; + + return this.prisma.$transaction(async (tx) => { + await tx.ticketType.update({ + where: { id: ticketTypeId }, + data: { quantityIssued: { increment: 1 } }, + }); + return tx.ticket.create({ + data: { + eventId: event.id, + ticketTypeId, + ownerId: buyerId, + chainTicketId, + seat: seat ?? 'unassigned', + issuedTxHash: txHash, + }, + }); + }); + } + + // ---- Direct transfer ---- + + async buildTransferTx(userId: string, ticketId: string, toUserId: string) { + const ticket = await this.getOwnedTicket(ticketId, userId); + const owner = await this.getUserWithWallet(userId); + const toUser = await this.getUserWithWallet(toUserId); + + const unsignedXdr = await this.stellar.buildTransferTicketTx({ + fromPublicKey: owner.stellarPublicKey!, + chainTicketId: ticket.chainTicketId, + toPublicKey: toUser.stellarPublicKey!, + }); + return { unsignedXdr }; + } + + async confirmTransfer( + userId: string, + ticketId: string, + toUserId: string, + signedXdr: string, + ) { + await this.getOwnedTicket(ticketId, userId); + await this.stellar.submitSignedTransaction(signedXdr); + return this.prisma.ticket.update({ + where: { id: ticketId }, + data: { ownerId: toUserId, status: TicketStatus.VALID }, + }); + } + + // ---- Verification (read-only, organizer/gate staff) ---- + + async verify(userId: string, qrSecret: string) { + const ticket = await this.prisma.ticket.findUnique({ + where: { qrSecret }, + include: { + event: { include: { organization: true } }, + owner: true, + ticketType: true, + }, + }); + if (!ticket) { + throw new NotFoundException('Ticket not found'); + } + await this.organizations.assertMember(ticket.event.organizationId, userId); + + const onChain = await this.stellar.verifyTicket(ticket.chainTicketId); + const reconciledStatus = onChain.status.toUpperCase() as TicketStatus; + if (reconciledStatus !== ticket.status) { + await this.prisma.ticket.update({ + where: { id: ticket.id }, + data: { status: reconciledStatus }, + }); + } + + return { + ticketId: ticket.id, + eventName: ticket.event.name, + tier: ticket.ticketType.name, + seat: ticket.seat, + ownerName: ticket.owner.name, + status: reconciledStatus, + onChainOwner: onChain.owner, + }; + } + + // ---- Check-in ---- + + async buildCheckInTx(userId: string, ticketId: string) { + const ticket = await this.getTicketWithOrg(ticketId); + await this.organizations.assertMember(ticket.event.organizationId, userId); + + const unsignedXdr = await this.stellar.buildCheckInTx({ + organizerPublicKey: ticket.event.organization.stellarAccount, + chainTicketId: ticket.chainTicketId, + }); + return { unsignedXdr }; + } + + async confirmCheckIn(userId: string, ticketId: string, signedXdr: string) { + const ticket = await this.getTicketWithOrg(ticketId); + await this.organizations.assertMember(ticket.event.organizationId, userId); + await this.stellar.submitSignedTransaction(signedXdr); + return this.prisma.ticket.update({ + where: { id: ticketId }, + data: { status: TicketStatus.USED, checkedInAt: new Date() }, + }); + } + + // ---- Revocation (fraud prevention) ---- + + async buildRevokeTx(userId: string, ticketId: string) { + const ticket = await this.getTicketWithOrg(ticketId); + await this.organizations.assertMember(ticket.event.organizationId, userId); + + const unsignedXdr = await this.stellar.buildRevokeTicketTx({ + organizerPublicKey: ticket.event.organization.stellarAccount, + chainTicketId: ticket.chainTicketId, + }); + return { unsignedXdr }; + } + + async confirmRevoke(userId: string, ticketId: string, signedXdr: string) { + const ticket = await this.getTicketWithOrg(ticketId); + await this.organizations.assertMember(ticket.event.organizationId, userId); + await this.stellar.submitSignedTransaction(signedXdr); + return this.prisma.ticket.update({ + where: { id: ticketId }, + data: { status: TicketStatus.REVOKED }, + }); + } + + // ---- Resale marketplace ---- + + async buildListForResaleTx(userId: string, ticketId: string, price: string) { + const ticket = await this.getOwnedTicket(ticketId, userId); + const owner = await this.getUserWithWallet(userId); + + const unsignedXdr = await this.stellar.buildListForResaleTx({ + ownerPublicKey: owner.stellarPublicKey!, + chainTicketId: ticket.chainTicketId, + price: BigInt(price), + }); + return { unsignedXdr }; + } + + async confirmListForResale( + userId: string, + ticketId: string, + price: string, + signedXdr: string, + ) { + await this.getOwnedTicket(ticketId, userId); + const { txHash } = await this.stellar.submitSignedTransaction(signedXdr); + + return this.prisma.$transaction(async (tx) => { + await tx.ticket.update({ + where: { id: ticketId }, + data: { status: TicketStatus.RESALE }, + }); + return tx.resaleListing.create({ + data: { ticketId, sellerId: userId, price: BigInt(price), txHash }, + }); + }); + } + + async buildCancelResaleTx(userId: string, ticketId: string) { + const ticket = await this.getOwnedTicket(ticketId, userId); + const owner = await this.getUserWithWallet(userId); + + const unsignedXdr = await this.stellar.buildCancelResaleTx({ + ownerPublicKey: owner.stellarPublicKey!, + chainTicketId: ticket.chainTicketId, + }); + return { unsignedXdr }; + } + + async confirmCancelResale( + userId: string, + ticketId: string, + signedXdr: string, + ) { + await this.getOwnedTicket(ticketId, userId); + await this.stellar.submitSignedTransaction(signedXdr); + + return this.prisma.$transaction(async (tx) => { + await tx.ticket.update({ + where: { id: ticketId }, + data: { status: TicketStatus.VALID }, + }); + await tx.resaleListing.updateMany({ + where: { ticketId, status: ResaleListingStatus.ACTIVE }, + data: { status: ResaleListingStatus.CANCELLED }, + }); + }); + } + + async buildBuyResaleTx(buyerId: string, ticketId: string) { + const ticket = await this.getTicketWithOrg(ticketId); + if (ticket.status !== TicketStatus.RESALE) { + throw new BadRequestException('This ticket is not listed for resale'); + } + const buyer = await this.getUserWithWallet(buyerId); + + const unsignedXdr = await this.stellar.buildBuyResaleTx({ + buyerPublicKey: buyer.stellarPublicKey!, + chainTicketId: ticket.chainTicketId, + }); + return { unsignedXdr }; + } + + async confirmBuyResale(buyerId: string, ticketId: string, signedXdr: string) { + await this.stellar.submitSignedTransaction(signedXdr); + + return this.prisma.$transaction(async (tx) => { + await tx.resaleListing.updateMany({ + where: { ticketId, status: ResaleListingStatus.ACTIVE }, + data: { status: ResaleListingStatus.SOLD }, + }); + return tx.ticket.update({ + where: { id: ticketId }, + data: { ownerId: buyerId, status: TicketStatus.VALID }, + }); + }); + } + + findActiveResaleListings() { + return this.prisma.resaleListing.findMany({ + where: { status: ResaleListingStatus.ACTIVE }, + include: { + ticket: { include: { event: true, ticketType: true } }, + seller: { select: { name: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + findMine(userId: string) { + return this.prisma.ticket.findMany({ + where: { ownerId: userId }, + include: { event: true, ticketType: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + // ---- shared helpers ---- + + private async getTicketTypeWithEvent(ticketTypeId: string) { + const ticketType = await this.prisma.ticketType.findUnique({ + where: { id: ticketTypeId }, + include: { event: { include: { organization: true } } }, + }); + if (!ticketType) { + throw new NotFoundException('Ticket type not found'); + } + return { ticketType, event: ticketType.event }; + } + + private async getTicketWithOrg(ticketId: string) { + const ticket = await this.prisma.ticket.findUnique({ + where: { id: ticketId }, + include: { event: { include: { organization: true } } }, + }); + if (!ticket) { + throw new NotFoundException('Ticket not found'); + } + return ticket; + } + + private async getOwnedTicket(ticketId: string, userId: string) { + const ticket = await this.getTicketWithOrg(ticketId); + if (ticket.ownerId !== userId) { + throw new ForbiddenException('You do not own this ticket'); + } + return ticket; + } + + private async getUserWithWallet(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + if (!user.stellarPublicKey) { + throw new BadRequestException( + 'Connect a Stellar wallet to your account before continuing', + ); + } + return user; + } + + private assertHasCapacity(issued: number, total: number) { + if (issued >= total) { + throw new BadRequestException('This ticket type is sold out'); + } + } +} diff --git a/src/users/dto/connect-wallet.dto.spec.ts b/src/users/dto/connect-wallet.dto.spec.ts new file mode 100644 index 0000000..f16ffd4 --- /dev/null +++ b/src/users/dto/connect-wallet.dto.spec.ts @@ -0,0 +1,21 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { ConnectWalletDto } from './connect-wallet.dto'; + +describe('ConnectWalletDto', () => { + it('accepts a valid Stellar public key', async () => { + const dto = plainToInstance(ConnectWalletDto, { + stellarPublicKey: + 'GBAHZWO3UI3GAHPQCPSW6IR5N7HJ4UBRZNAFMSYB6DAKVNHQDOZIV2YJ', + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('rejects a malformed public key', async () => { + const dto = plainToInstance(ConnectWalletDto, { + stellarPublicKey: 'not-a-key', + }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'stellarPublicKey')).toBe(true); + }); +}); diff --git a/src/users/dto/connect-wallet.dto.ts b/src/users/dto/connect-wallet.dto.ts new file mode 100644 index 0000000..1631eed --- /dev/null +++ b/src/users/dto/connect-wallet.dto.ts @@ -0,0 +1,6 @@ +import { IsStellarPublicKey } from '../../common/decorators/is-stellar-public-key.decorator'; + +export class ConnectWalletDto { + @IsStellarPublicKey() + stellarPublicKey: string; +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts new file mode 100644 index 0000000..109956b --- /dev/null +++ b/src/users/users.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, Get, Patch, Query, UseGuards } from '@nestjs/common'; +import { IsEmail } from 'class-validator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { CurrentUserPayload } from '../auth/decorators/current-user.decorator'; +import { UsersService } from './users.service'; +import { ConnectWalletDto } from './dto/connect-wallet.dto'; + +class LookupQuery { + @IsEmail() + email: string; +} + +@Controller('users') +@UseGuards(JwtAuthGuard) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get('me') + findMe(@CurrentUser() user: CurrentUserPayload) { + return this.usersService.findMe(user.userId); + } + + @Patch('me/wallet') + connectWallet( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: ConnectWalletDto, + ) { + return this.usersService.connectWallet(user.userId, dto.stellarPublicKey); + } + + @Get('lookup') + lookupByEmail(@Query() query: LookupQuery) { + return this.usersService.lookupByEmail(query.email); + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts new file mode 100644 index 0000000..440ef36 --- /dev/null +++ b/src/users/users.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + controllers: [UsersController], + providers: [UsersService], +}) +export class UsersModule {} diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts new file mode 100644 index 0000000..90beab5 --- /dev/null +++ b/src/users/users.service.spec.ts @@ -0,0 +1,67 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { UsersService } from './users.service'; +import type { PrismaService } from '../prisma/prisma.service'; + +describe('UsersService', () => { + let service: UsersService; + let prisma: { user: { findUnique: jest.Mock; update: jest.Mock } }; + + beforeEach(() => { + prisma = { user: { findUnique: jest.fn(), update: jest.fn() } }; + service = new UsersService(prisma as unknown as PrismaService); + }); + + describe('findMe', () => { + it('throws NotFoundException when the user no longer exists', async () => { + prisma.user.findUnique.mockResolvedValue(null); + + await expect(service.findMe('user-1')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('returns the profile projection', async () => { + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: 'a@b.com', + }); + + const result = await service.findMe('user-1'); + expect(result).toEqual({ id: 'user-1', email: 'a@b.com' }); + }); + }); + + describe('connectWallet', () => { + it('rejects a wallet already connected to another account', async () => { + prisma.user.update.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', + clientVersion: '6.19.3', + }), + ); + + await expect( + service.connectWallet('user-1', 'GABC'), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('rethrows unrelated errors', async () => { + prisma.user.update.mockRejectedValue(new Error('connection lost')); + + await expect(service.connectWallet('user-1', 'GABC')).rejects.toThrow( + 'connection lost', + ); + }); + }); + + describe('lookupByEmail', () => { + it('throws NotFoundException when no account matches', async () => { + prisma.user.findUnique.mockResolvedValue(null); + + await expect( + service.lookupByEmail('nobody@example.com'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); +}); diff --git a/src/users/users.service.ts b/src/users/users.service.ts new file mode 100644 index 0000000..73c5c89 --- /dev/null +++ b/src/users/users.service.ts @@ -0,0 +1,62 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async findMe(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + name: true, + role: true, + stellarPublicKey: true, + createdAt: true, + }, + }); + if (!user) { + throw new NotFoundException('User not found'); + } + return user; + } + + async connectWallet(userId: string, stellarPublicKey: string) { + try { + return await this.prisma.user.update({ + where: { id: userId }, + data: { stellarPublicKey }, + select: { id: true, stellarPublicKey: true }, + }); + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + throw new ConflictException( + 'That wallet is already connected to another account', + ); + } + throw err; + } + } + + /** Used by organizers/attendees to resolve a recipient before issuing or transferring a ticket. */ + async lookupByEmail(email: string) { + const user = await this.prisma.user.findUnique({ + where: { email }, + select: { id: true, name: true, email: true, stellarPublicKey: true }, + }); + if (!user) { + throw new NotFoundException('No account found with that email'); + } + return user; + } +} diff --git a/src/utils/circuitBreaker.js b/src/utils/circuitBreaker.js deleted file mode 100644 index 03fef49..0000000 --- a/src/utils/circuitBreaker.js +++ /dev/null @@ -1,143 +0,0 @@ -'use strict'; - -const logger = require('../logger'); - -const STATES = Object.freeze({ - CLOSED: 'closed', - OPEN: 'open', - HALF_OPEN: 'half-open', -}); - -class CircuitBreaker { - constructor(name, options = {}) { - this.name = name; - this.failureThreshold = Math.max(1, options.failureThreshold ?? 3); - this.successThreshold = Math.max(1, options.successThreshold ?? 1); - this.timeoutMs = Math.max(1, options.timeoutMs ?? 30000); - this._now = options.now || Date.now; - this._logger = options.logger || logger; - - this.state = STATES.CLOSED; - this.failureCount = 0; - this.successCount = 0; - this.openedAt = null; - this.halfOpenInFlight = false; - } - - getState() { - this._moveToHalfOpenIfReady(); - return this.state; - } - - isOpen() { - return this.getState() === STATES.OPEN; - } - - async call(fn) { - this._moveToHalfOpenIfReady(); - - if (this.state === STATES.OPEN) { - this._logger.info('Circuit breaker open, skipping source call', { - source: this.name, - state: this.state, - }); - return null; - } - - if (this.state === STATES.HALF_OPEN && this.halfOpenInFlight) { - this._logger.info('Circuit breaker half-open probe already in flight, skipping source call', { - source: this.name, - state: this.state, - }); - return null; - } - - const probing = this.state === STATES.HALF_OPEN; - if (probing) { - this.halfOpenInFlight = true; - } - - try { - const result = await fn(); - if (result === null || result === undefined) { - this.recordFailure(); - } else { - this.recordSuccess(); - } - return result ?? null; - } catch (err) { - this.recordFailure(); - throw err; - } finally { - if (probing) { - this.halfOpenInFlight = false; - } - } - } - - recordSuccess() { - if (this.state === STATES.HALF_OPEN) { - this.successCount += 1; - if (this.successCount >= this.successThreshold) { - this._transitionTo(STATES.CLOSED, { reason: 'success-threshold' }); - } - return; - } - - if (this.state === STATES.CLOSED) { - this.failureCount = 0; - } - } - - recordFailure() { - if (this.state === STATES.HALF_OPEN) { - this._transitionTo(STATES.OPEN, { reason: 'half-open-failure' }); - return; - } - - if (this.state === STATES.CLOSED) { - this.failureCount += 1; - if (this.failureCount >= this.failureThreshold) { - this._transitionTo(STATES.OPEN, { reason: 'failure-threshold' }); - } - } - } - - reset() { - this._transitionTo(STATES.CLOSED, { reason: 'manual-reset' }); - } - - _moveToHalfOpenIfReady() { - if (this.state !== STATES.OPEN || this.openedAt === null) { - return; - } - - if (this._now() - this.openedAt >= this.timeoutMs) { - this._transitionTo(STATES.HALF_OPEN, { reason: 'cooldown-elapsed' }); - } - } - - _transitionTo(nextState, metadata = {}) { - if (this.state === nextState) { - return; - } - - const previousState = this.state; - this.state = nextState; - this.failureCount = 0; - this.successCount = 0; - this.openedAt = nextState === STATES.OPEN ? this._now() : null; - - this._logger.info('Circuit breaker state changed', { - source: this.name, - from: previousState, - to: nextState, - ...metadata, - }); - } -} - -module.exports = { - CircuitBreaker, - STATES, -}; diff --git a/src/utils/paginate.js b/src/utils/paginate.js deleted file mode 100644 index b2b9779..0000000 --- a/src/utils/paginate.js +++ /dev/null @@ -1,23 +0,0 @@ -function parsePagination(query, { maxLimit = 100 } = {}) { - const page = Math.max(1, parseInt(query.page) || 1); - const limit = Math.min(maxLimit, Math.max(1, parseInt(query.limit) || 20)); - const offset = (page - 1) * limit; - return { page, limit, offset }; -} - -function paginateResponse(data, total, { page, limit }) { - const total_pages = Math.ceil(total / limit); - return { - data, - pagination: { - page, limit, total, total_pages, - has_next: page < total_pages, - has_prev: page > 1, - }, - }; -} - -module.exports = { - parsePagination, - paginateResponse -}; \ No newline at end of file diff --git a/src/validation/schemas.js b/src/validation/schemas.js deleted file mode 100644 index afc89e6..0000000 --- a/src/validation/schemas.js +++ /dev/null @@ -1,195 +0,0 @@ -'use strict'; - -const { z } = require('zod'); -const webhookEvents = require('../services/webhookEvents'); - -const stellarPublicKeySchema = z - .string() - .regex(/^G[A-Z0-9]{55}$/, 'Must be a valid Stellar public key'); - -const assetCodeSchema = z - .string() - .trim() - .min(1, 'Asset code is required') - .max(12, 'Asset code must be 12 characters or fewer') - .regex(/^[A-Za-z0-9]+$/, 'Asset code must be alphanumeric') - .transform((value) => value.toUpperCase()); - -const optionalIssuerSchema = z.preprocess( - (value) => (value === '' ? undefined : value), - stellarPublicKeySchema.optional() -); - -const paginationQuerySchema = z.object({ - page: z.coerce.number().int().min(1).default(1), - limit: z.coerce.number().int().min(1).max(100).default(20), -}); - -const routeIdParamsSchema = z.object({ - id: z - .string() - .trim() - .min(1) - .max(128) - .regex(/^[A-Za-z0-9_-]+$/, 'ID can contain only letters, numbers, underscores, and hyphens'), -}); - -const httpUrlSchema = z - .string() - .trim() - .refine((value) => { - try { - const url = new URL(value); - return ['http:', 'https:'].includes(url.protocol); - } catch { - return false; - } - }, { - message: 'Must be an http(s) URL', - }); - -const priceParamsSchema = z.object({ - asset_code: assetCodeSchema, -}); - -const priceQuerySchema = z.object({ - issuer: optionalIssuerSchema, -}); - -const keyCreateBodySchema = z.object({ - label: z.string().trim().min(1).max(80), - scopes: z - .array(z.string().trim().min(1)) - .nonempty() - .optional(), -}); - -const alertCreateBodySchema = z.object({ - asset: assetCodeSchema, - type: z.enum(['above', 'below', 'change_pct']), - threshold_usd: z.number().positive(), - webhook_url: httpUrlSchema, - webhook_secret: z.string().min(8), - repeat: z.boolean().optional(), -}); - -const webhookSubscriptionSchema = z - .array(z.string()) - .nonempty() - .refine((value) => webhookEvents.isValidSubscription(value), { - message: `Must contain ${webhookEvents.WILDCARD} or known events`, - }); - -const webhookCreateBodySchema = z.object({ - url: httpUrlSchema, - events: webhookSubscriptionSchema, - secret: z.string().min(16).optional(), - description: z.string().optional(), -}); - -const webhookPatchBodySchema = z.object({ - url: httpUrlSchema.optional(), - events: webhookSubscriptionSchema.optional(), - secret: z.string().min(16).optional(), - active: z.boolean().optional(), - description: z.string().optional(), -}); - -const webhookDeliveriesQuerySchema = z.object({ - limit: z.coerce.number().int().min(1).max(100).default(50), -}); - -const recipientSchema = z.object({ - address: stellarPublicKeySchema, - amount: z.number().positive(), -}); - -const recipientsSchema = z - .array(recipientSchema) - .max(10000, 'recipients cannot exceed 10,000') - .superRefine((recipients, ctx) => { - const seen = new Set(); - recipients.forEach((recipient, index) => { - if (seen.has(recipient.address)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [index, 'address'], - message: `recipient ${index}: duplicate address ${recipient.address}`, - }); - } - seen.add(recipient.address); - }); - }); - -function expiryLedgerSchema(currentLedger) { - return z - .number() - .int() - .gt(currentLedger, `expiry_ledger must be greater than current ledger (${currentLedger})`); -} - -function airdropCreateBodySchema(currentLedger) { - return z - .object({ - name: z.string().trim().min(1), - description: z.string().optional(), - asset: assetCodeSchema, - asset_issuer: stellarPublicKeySchema, - total_amount: z.number().positive(), - expiry_ledger: expiryLedgerSchema(currentLedger), - recipients: recipientsSchema.optional().default([]), - }) - .superRefine((body, ctx) => { - if (body.recipients.length === 0) return; - - const total = body.recipients.reduce((sum, recipient) => sum + recipient.amount, 0); - if (total !== body.total_amount) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['recipients'], - message: `sum of recipient amounts (${total}) must equal total_amount (${body.total_amount})`, - }); - } - }); -} - -function airdropUpdateBodySchema(currentLedger) { - return z.object({ - name: z.string().trim().min(1).optional(), - description: z.string().optional(), - expiry_ledger: expiryLedgerSchema(currentLedger).optional(), - }); -} - -const airdropRecipientsBodySchema = z.object({ - recipients: z.preprocess((value) => { - if (typeof value !== 'string') return value; - - try { - return JSON.parse(value); - } catch { - return value; - } - }, recipientsSchema.optional()), -}); - -module.exports = { - airdropCreateBodySchema, - airdropRecipientsBodySchema, - airdropUpdateBodySchema, - alertCreateBodySchema, - assetCodeSchema, - httpUrlSchema, - keyCreateBodySchema, - optionalIssuerSchema, - paginationQuerySchema, - priceParamsSchema, - priceQuerySchema, - recipientsSchema, - routeIdParamsSchema, - stellarPublicKeySchema, - webhookCreateBodySchema, - webhookDeliveriesQuerySchema, - webhookPatchBodySchema, - webhookSubscriptionSchema, -}; diff --git a/src/ws/PriceSubscriptionManager.js b/src/ws/PriceSubscriptionManager.js deleted file mode 100644 index 7a914dc..0000000 --- a/src/ws/PriceSubscriptionManager.js +++ /dev/null @@ -1,174 +0,0 @@ -'use strict'; - -const logger = require('../logger'); - -const MAX_ASSETS_PER_CLIENT = 5; -const MAX_CONNECTIONS = 100; -const PING_INTERVAL_MS = 30_000; -const MAX_MISSED_PINGS = 3; -const PRICE_CHANGE_THRESHOLD_PCT = 0.1; - -// Prometheus gauge — updated whenever a socket connects or disconnects. -let wsConnectionsGauge = null; -try { - const prom = require('prom-client'); - wsConnectionsGauge = new prom.Gauge({ - name: 'ws_connections_current', - help: 'Number of currently active WebSocket connections', - }); -} catch { - // prom-client not installed; gauge is a no-op. -} - -function updateGauge(delta) { - if (wsConnectionsGauge) wsConnectionsGauge.inc(delta); -} - -/** - * Tracks WebSocket subscriptions and delivers price-change pushes. - * - * Each socket entry: - * { ws, assets: Set, missedPings: number } - */ -class PriceSubscriptionManager { - constructor() { - this._clients = new Map(); // ws → { assets, missedPings } - this._previousPrices = new Map(); // assetKey → number - this._pingTimer = null; - } - - /** Register a new WebSocket connection. Returns false when at capacity. */ - add(ws) { - if (this._clients.size >= MAX_CONNECTIONS) { - ws.close(1013, 'Max connections reached'); - return false; - } - - this._clients.set(ws, { assets: new Set(), missedPings: 0 }); - updateGauge(1); - logger.info('WS client connected', { total: this._clients.size }); - - ws.on('message', (raw) => this._handleMessage(ws, raw)); - ws.on('close', () => this._remove(ws)); - ws.on('error', (err) => { - logger.warn('WS client error', { error: err.message }); - this._remove(ws); - }); - - return true; - } - - _remove(ws) { - if (!this._clients.has(ws)) return; - this._clients.delete(ws); - updateGauge(-1); - logger.info('WS client disconnected', { total: this._clients.size }); - } - - _handleMessage(ws, raw) { - let msg; - try { - msg = JSON.parse(raw.toString()); - } catch { - this._send(ws, { type: 'error', message: 'Invalid JSON' }); - return; - } - - const client = this._clients.get(ws); - if (!client) return; - - if (msg.action === 'subscribe') { - const requested = Array.isArray(msg.assets) ? msg.assets : []; - const allowed = requested.slice(0, MAX_ASSETS_PER_CLIENT); - for (const a of allowed) client.assets.add(String(a)); - this._send(ws, { type: 'subscribed', assets: [...client.assets] }); - - } else if (msg.action === 'unsubscribe') { - const toRemove = Array.isArray(msg.assets) ? msg.assets : []; - for (const a of toRemove) client.assets.delete(String(a)); - this._send(ws, { type: 'unsubscribed', assets: [...client.assets] }); - - } else if (msg.action === 'pong') { - client.missedPings = 0; - - } else { - this._send(ws, { type: 'error', message: `Unknown action: ${msg.action}` }); - } - } - - _send(ws, payload) { - if (ws.readyState !== ws.constructor.OPEN) return; - try { - ws.send(JSON.stringify(payload)); - } catch (err) { - logger.warn('WS send failed', { error: err.message }); - } - } - - /** - * Called after each price refresh cycle with a map of assetKey → newPrice. - * Pushes updates to subscribers whose watched asset changed by > 0.1%. - */ - notifyPriceUpdates(freshPrices) { - for (const [assetKey, { price, source }] of Object.entries(freshPrices)) { - const prev = this._previousPrices.get(assetKey); - - if (prev !== undefined && prev > 0) { - const changePct = ((price - prev) / prev) * 100; - if (Math.abs(changePct) > PRICE_CHANGE_THRESHOLD_PCT) { - const update = { - type: 'price_update', - asset: assetKey, - price_usd: price, - previous_price_usd: prev, - change_pct: parseFloat(changePct.toFixed(4)), - source, - timestamp: new Date().toISOString(), - }; - this._broadcast(assetKey, update); - } - } - - this._previousPrices.set(assetKey, price); - } - } - - _broadcast(assetKey, payload) { - for (const [ws, client] of this._clients) { - if (client.assets.has(assetKey)) { - this._send(ws, payload); - } - } - } - - /** Start sending heartbeat pings every 30 s; disconnect idle sockets. */ - startHeartbeat() { - if (this._pingTimer) return; - this._pingTimer = setInterval(() => { - for (const [ws, client] of this._clients) { - if (client.missedPings >= MAX_MISSED_PINGS) { - logger.info('WS client timed out, disconnecting'); - ws.terminate(); - this._remove(ws); - continue; - } - client.missedPings += 1; - this._send(ws, { type: 'ping' }); - } - }, PING_INTERVAL_MS); - } - - stopHeartbeat() { - if (this._pingTimer) { - clearInterval(this._pingTimer); - this._pingTimer = null; - } - } - - get connectionCount() { - return this._clients.size; - } -} - -module.exports = new PriceSubscriptionManager(); -module.exports.PriceSubscriptionManager = PriceSubscriptionManager; diff --git a/src/ws/priceWebSocket.js b/src/ws/priceWebSocket.js deleted file mode 100644 index bb23984..0000000 --- a/src/ws/priceWebSocket.js +++ /dev/null @@ -1,29 +0,0 @@ -'use strict'; - -const { WebSocketServer } = require('ws'); -const logger = require('../logger'); -const subscriptionManager = require('./PriceSubscriptionManager'); - -/** - * Attach the WebSocket server to an existing HTTP server. - * Clients connect at ws:///ws - */ -function attach(httpServer) { - const wss = new WebSocketServer({ server: httpServer, path: '/ws' }); - - wss.on('connection', (ws, req) => { - logger.info('Incoming WS connection', { ip: req.socket.remoteAddress }); - subscriptionManager.add(ws); - }); - - wss.on('error', (err) => { - logger.error('WebSocket server error', { error: err.message }); - }); - - subscriptionManager.startHeartbeat(); - logger.info('WebSocket price-stream server attached at /ws'); - - return wss; -} - -module.exports = { attach }; diff --git a/test/airdropExpiry.test.js b/test/airdropExpiry.test.js deleted file mode 100644 index 7f62218..0000000 --- a/test/airdropExpiry.test.js +++ /dev/null @@ -1,199 +0,0 @@ -'use strict'; - -const mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -}; - -const mockAirdropsService = { - getCurrentLedger: jest.fn(), - scanIds: jest.fn(), - get: jest.fn(), - markExpired: jest.fn(), - TERMINAL_STATUSES: new Set(['completed', 'failed', 'cancelled', 'expired']), -}; - -const mockDispatch = jest.fn(); - -jest.mock('../src/logger', () => mockLogger); -jest.mock('../src/services/airdrops', () => mockAirdropsService); -jest.mock('../src/services/webhookDispatcher', () => ({ dispatch: mockDispatch })); -jest.mock('../src/config', () => ({ - airdrops: { - expiryCheckIntervalSeconds: 60, - ledgerCacheTtlMs: 5000, - expiryScanBatchSize: 100, - }, -})); - -// scanIds() is an async generator in the real service; this mock accepts a -// plain array of batches and yields them the same way. -function mockScanIdsReturning(batches) { - mockAirdropsService.scanIds.mockReturnValue( - (async function* () { - for (const batch of batches) yield batch; - })() - ); -} - -function draftAirdrop(overrides = {}) { - return { - id: 'drop_1', - status: 'draft', - expiry_ledger: 100, - ...overrides, - }; -} - -const { tick } = require('../src/jobs/airdropExpiry'); - -beforeEach(() => { - jest.clearAllMocks(); - mockAirdropsService.TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'expired']); -}); - -describe('airdropExpiry job tick (#88)', () => { - test('expires an airdrop past its expiry_ledger and dispatches airdrop.failed exactly once', async () => { - mockAirdropsService.getCurrentLedger.mockResolvedValue(150); - mockScanIdsReturning([['drop_1']]); - mockAirdropsService.get.mockResolvedValue(draftAirdrop({ expiry_ledger: 100 })); - mockAirdropsService.markExpired.mockResolvedValue( - draftAirdrop({ status: 'expired', expiry_ledger: 100 }) - ); - - await tick(); - - expect(mockAirdropsService.markExpired).toHaveBeenCalledWith('drop_1', 150); - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - event_type: 'airdrop.failed', - data: expect.objectContaining({ airdrop_id: 'drop_1', reason: 'expired' }), - }) - ); - }); - - test('leaves a non-expired airdrop untouched', async () => { - mockAirdropsService.getCurrentLedger.mockResolvedValue(50); - mockScanIdsReturning([['drop_1']]); - mockAirdropsService.get.mockResolvedValue(draftAirdrop({ expiry_ledger: 100 })); - - await tick(); - - expect(mockAirdropsService.markExpired).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - test('skips an airdrop already in a terminal status without attempting a transition', async () => { - mockAirdropsService.getCurrentLedger.mockResolvedValue(150); - mockScanIdsReturning([['drop_1']]); - mockAirdropsService.get.mockResolvedValue(draftAirdrop({ status: 'cancelled', expiry_ledger: 100 })); - - await tick(); - - expect(mockAirdropsService.markExpired).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - test('does not dispatch when markExpired reports no transition happened (lost a race)', async () => { - mockAirdropsService.getCurrentLedger.mockResolvedValue(150); - mockScanIdsReturning([['drop_1']]); - mockAirdropsService.get.mockResolvedValue(draftAirdrop({ expiry_ledger: 100 })); - mockAirdropsService.markExpired.mockResolvedValue(null); - - await tick(); - - expect(mockAirdropsService.markExpired).toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - test('is idempotent across two ticks: the webhook fires exactly once total', async () => { - mockAirdropsService.getCurrentLedger.mockResolvedValue(150); - mockScanIdsReturning([['drop_1']]); - mockAirdropsService.get.mockResolvedValue(draftAirdrop({ expiry_ledger: 100 })); - mockAirdropsService.markExpired.mockResolvedValueOnce( - draftAirdrop({ status: 'expired', expiry_ledger: 100 }) - ); - - await tick(); - - // Second tick: the airdrop is now expired (terminal), matching what a - // real second scan would see after the first tick's transition landed. - mockScanIdsReturning([['drop_1']]); - mockAirdropsService.get.mockResolvedValue(draftAirdrop({ status: 'expired', expiry_ledger: 100 })); - - await tick(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - }); - - test('a Horizon failure logs a warning and does not throw or touch any airdrop', async () => { - mockAirdropsService.getCurrentLedger.mockRejectedValue(new Error('Horizon unreachable')); - - await expect(tick()).resolves.toBeUndefined(); - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Horizon unreachable'), - expect.objectContaining({ error: 'Horizon unreachable' }) - ); - expect(mockAirdropsService.scanIds).not.toHaveBeenCalled(); - expect(mockAirdropsService.markExpired).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - test('a dispatch failure is logged and does not throw out of the tick', async () => { - mockAirdropsService.getCurrentLedger.mockResolvedValue(150); - mockScanIdsReturning([['drop_1']]); - mockAirdropsService.get.mockResolvedValue(draftAirdrop({ expiry_ledger: 100 })); - mockAirdropsService.markExpired.mockResolvedValue( - draftAirdrop({ status: 'expired', expiry_ledger: 100 }) - ); - mockDispatch.mockRejectedValue(new Error('webhook target unreachable')); - - await expect(tick()).resolves.toBeUndefined(); - - expect(mockLogger.error).toHaveBeenCalledWith( - 'Airdrop expiry webhook dispatch failed', - expect.objectContaining({ airdrop_id: 'drop_1' }) - ); - }); - - test('handles multiple airdrops across multiple scan batches', async () => { - mockAirdropsService.getCurrentLedger.mockResolvedValue(150); - mockScanIdsReturning([['drop_1'], ['drop_2']]); - mockAirdropsService.get.mockImplementation(async (id) => - draftAirdrop({ id, expiry_ledger: 100 }) - ); - mockAirdropsService.markExpired.mockImplementation(async (id) => - draftAirdrop({ id, status: 'expired', expiry_ledger: 100 }) - ); - - await tick(); - - expect(mockAirdropsService.markExpired).toHaveBeenCalledTimes(2); - expect(mockDispatch).toHaveBeenCalledTimes(2); - }); - - test('a per-airdrop read error is logged and does not stop the rest of the scan', async () => { - mockAirdropsService.getCurrentLedger.mockResolvedValue(150); - mockScanIdsReturning([['drop_1', 'drop_2']]); - mockAirdropsService.get.mockImplementation(async (id) => { - if (id === 'drop_1') throw new Error('redis timeout'); - return draftAirdrop({ id, expiry_ledger: 100 }); - }); - mockAirdropsService.markExpired.mockResolvedValue( - draftAirdrop({ id: 'drop_2', status: 'expired', expiry_ledger: 100 }) - ); - - await tick(); - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('failed to read airdrop'), - expect.objectContaining({ airdrop_id: 'drop_1' }) - ); - expect(mockAirdropsService.markExpired).toHaveBeenCalledWith('drop_2', 150); - expect(mockDispatch).toHaveBeenCalledTimes(1); - }); -}); diff --git a/test/airdrops-service.test.js b/test/airdrops-service.test.js deleted file mode 100644 index 99605f4..0000000 --- a/test/airdrops-service.test.js +++ /dev/null @@ -1,297 +0,0 @@ -'use strict'; - -const mockStore = new Map(); -const mockSets = new Map(); -const mockZSets = new Map(); -const mockLists = new Map(); - -// Faithfully mirrors MARK_EXPIRED_SCRIPT's condition/write logic in JS, -// since jest can't execute real Lua against a live Redis in this test -// environment. Operates on the same `mockStore` cache.get/set already use -// (real Redis: both the Lua script and cache.get/set ultimately read/write -// the one physical `airdrop:` key) — kept as a literal translation of -// the script's checks, not a "smarter" reimplementation, to minimize the -// risk of this mock silently diverging from what the real script does. -const TERMINAL_STATUSES_FOR_MOCK = new Set(['completed', 'failed', 'cancelled', 'expired']); -function mockMarkExpiredEval(store, key, currentLedger, nowIso) { - const airdrop = store.get(key); - if (airdrop === undefined) return null; - if (TERMINAL_STATUSES_FOR_MOCK.has(airdrop.status)) return null; - if (!airdrop.expiry_ledger || Number(airdrop.expiry_ledger) > Number(currentLedger)) return null; - const updated = { ...airdrop, status: 'expired', updated_at: nowIso }; - store.set(key, updated); - return JSON.stringify(updated); -} - -function getSortedZSetMembers(key) { - const z = mockZSets.get(key); - if (!z) return []; - return [...z.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([member]) => member); -} - -const mockRedis = { - smembers: jest.fn(async (key) => [...(mockSets.get(key) || [])]), - sadd: jest.fn(async (key, val) => { - if (!mockSets.has(key)) mockSets.set(key, new Set()); - mockSets.get(key).add(val); - }), - srem: jest.fn(async (key, val) => { - mockSets.get(key)?.delete(val); - }), - // Paginated cursor mock: indexes into the set's insertion order, returning - // up to `count` members per call and a numeric cursor (as a string, like - // real Redis) until exhausted, at which point it returns cursor '0'. - sscan: jest.fn(async (key, cursor, _countKeyword, count) => { - const members = [...(mockSets.get(key) || [])]; - const start = Number(cursor); - const batch = members.slice(start, start + count); - const nextCursor = start + count >= members.length ? '0' : String(start + count); - return [nextCursor, batch]; - }), - zadd: jest.fn(async (key, score, member) => { - if (!mockZSets.has(key)) mockZSets.set(key, new Map()); - mockZSets.get(key).set(member, Number(score)); - }), - zrem: jest.fn(async (key, ...members) => { - const z = mockZSets.get(key); - if (!z) return; - for (const m of members) z.delete(m); - }), - zrevrange: jest.fn(async (key, start, stop) => { - const sorted = getSortedZSetMembers(key); - const end = stop === -1 ? sorted.length : stop + 1; - return sorted.slice(start, end); - }), - zcard: jest.fn(async (key) => (mockZSets.get(key)?.size || 0)), - zscan: jest.fn(async (key, cursor, _countKeyword, count) => { - const entries = [...(mockZSets.get(key)?.entries() || [])]; - const batchWithScores = []; - const start = Number(cursor); - for (let i = start; i < start + count && i < entries.length; i++) { - batchWithScores.push(entries[i][0], entries[i][1]); - } - const nextCursor = start + count >= entries.length ? '0' : String(start + count); - return [nextCursor, batchWithScores]; - }), - llen: jest.fn(async (key) => (mockLists.get(key) || []).length), - lpush: jest.fn(async (key, ...vals) => { - if (!mockLists.has(key)) mockLists.set(key, []); - mockLists.get(key).unshift(...vals); - }), - rpush: jest.fn(async (key, ...vals) => { - if (!mockLists.has(key)) mockLists.set(key, []); - mockLists.get(key).push(...vals); - }), - lrange: jest.fn(async (key, start, end) => { - const list = mockLists.get(key) || []; - return list.slice(start, end + 1); - }), - // Only understands MARK_EXPIRED_SCRIPT's exact call shape - // (eval(script, 1, key, currentLedger, nowIso)) — sufficient since - // markExpired() is the only caller of redis.eval in this codebase. - eval: jest.fn(async (_script, _numKeys, key, currentLedger, nowIso) => - mockMarkExpiredEval(mockStore, key, currentLedger, nowIso) - ), -}; - -jest.mock('../src/services/cache', () => ({ - getClient: () => mockRedis, - get: jest.fn(async (key) => { - const v = mockStore.get(key); - return v !== undefined ? JSON.parse(JSON.stringify(v)) : null; - }), - set: jest.fn(async (key, value) => { - mockStore.set(key, JSON.parse(JSON.stringify(value))); - }), - del: jest.fn(async (key) => { - mockStore.delete(key); - mockLists.delete(key); - }), -})); - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -const mockLedger = { sequence: 12345 }; -const mockHorizonCall = jest.fn(async () => ({ records: [mockLedger] })); -jest.mock('stellar-sdk', () => ({ - Horizon: { - Server: jest.fn(() => ({ - ledgers: jest.fn(() => ({ - order: jest.fn(() => ({ - limit: jest.fn(() => ({ - call: mockHorizonCall, - })), - })), - })), - })), - }, - StrKey: { - isValidEd25519PublicKey: jest.fn((address) => address.startsWith('G') && address.length === 56), - }, -})); - -const airdropsService = require('../src/services/airdrops'); - -beforeEach(() => { - mockStore.clear(); - mockSets.clear(); - mockZSets.clear(); - mockLists.clear(); -}); - -describe('airdrops service', () => { - test('create and get airdrop', async () => { - const airdrop = await airdropsService.create({ - name: 'Test', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - - console.log('Created airdrop:', airdrop); - console.log('mockStore contents:', Array.from(mockStore.entries())); - console.log('mockSets contents:', Array.from(mockSets.entries())); - - const fetched = await airdropsService.get(airdrop.id); - console.log('Fetched airdrop:', fetched); - - expect(fetched).not.toBeNull(); - expect(fetched.id).toBe(airdrop.id); - }); - - describe('getCurrentLedger caching (#88)', () => { - beforeEach(() => { - mockHorizonCall.mockClear(); - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - test('reuses the cached ledger within the TTL instead of calling Horizon again', async () => { - jest.resetModules(); - const freshService = require('../src/services/airdrops'); - - const first = await freshService.getCurrentLedger(); - const second = await freshService.getCurrentLedger(); - - expect(first).toBe(12345); - expect(second).toBe(12345); - expect(mockHorizonCall).toHaveBeenCalledTimes(1); - }); - - test('calls Horizon again once the cache TTL has elapsed', async () => { - jest.resetModules(); - const freshService = require('../src/services/airdrops'); - - await freshService.getCurrentLedger(); - jest.advanceTimersByTime(5001); - await freshService.getCurrentLedger(); - - expect(mockHorizonCall).toHaveBeenCalledTimes(2); - }); - }); - - describe('scanIds (#88)', () => { - test('pages through every ID in the set across multiple ZSCAN batches', async () => { - for (let i = 0; i < 5; i++) { - await airdropsService.create({ - name: `Airdrop ${i}`, - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - } - - const seen = []; - for await (const batch of airdropsService.scanIds(2)) { - seen.push(...batch); - } - - expect(seen).toHaveLength(5); - expect(new Set(seen).size).toBe(5); - // Confirms it actually paged (more than one ZSCAN call for 5 items at - // batch size 2), not just a single ZREVRANGE-style dump. - expect(mockRedis.zscan.mock.calls.length).toBeGreaterThan(1); - }); - - test('yields nothing for an empty airdrop set', async () => { - const seen = []; - for await (const batch of airdropsService.scanIds(2)) { - seen.push(...batch); - } - expect(seen).toHaveLength(0); - }); - }); - - describe('markExpired (#88)', () => { - async function createAirdrop(overrides = {}) { - return airdropsService.create({ - name: 'Test', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 100, - ...overrides, - }); - } - - test('transitions a draft airdrop past its expiry_ledger to expired', async () => { - const airdrop = await createAirdrop({ expiry_ledger: 100 }); - - const updated = await airdropsService.markExpired(airdrop.id, 150); - - expect(updated).not.toBeNull(); - expect(updated.status).toBe('expired'); - const stored = await airdropsService.get(airdrop.id); - expect(stored.status).toBe('expired'); - }); - - test('is a no-op for an airdrop not yet past its expiry_ledger', async () => { - const airdrop = await createAirdrop({ expiry_ledger: 200 }); - - const updated = await airdropsService.markExpired(airdrop.id, 150); - - expect(updated).toBeNull(); - const stored = await airdropsService.get(airdrop.id); - expect(stored.status).toBe('draft'); - }); - - test('is idempotent: a second call against an already-expired airdrop no-ops', async () => { - const airdrop = await createAirdrop({ expiry_ledger: 100 }); - - const firstCall = await airdropsService.markExpired(airdrop.id, 150); - const secondCall = await airdropsService.markExpired(airdrop.id, 150); - - expect(firstCall).not.toBeNull(); - expect(secondCall).toBeNull(); - }); - - test('does not transition an airdrop already in a terminal status', async () => { - const airdrop = await createAirdrop({ expiry_ledger: 100 }); - await airdropsService.cancel(airdrop.id); - - const updated = await airdropsService.markExpired(airdrop.id, 150); - - expect(updated).toBeNull(); - const stored = await airdropsService.get(airdrop.id); - expect(stored.status).toBe('cancelled'); - }); - - test('returns null for a nonexistent airdrop id', async () => { - const updated = await airdropsService.markExpired('drop_does_not_exist', 150); - expect(updated).toBeNull(); - }); - }); -}); diff --git a/test/airdrops.test.js b/test/airdrops.test.js deleted file mode 100644 index d2a9068..0000000 --- a/test/airdrops.test.js +++ /dev/null @@ -1,487 +0,0 @@ -'use strict'; - -const mockStore = new Map(); -const mockSets = new Map(); -const mockZSets = new Map(); -const mockLists = new Map(); -const mockCounters = new Map(); - -const mockRedis = { - smembers: jest.fn(async (key) => [...(mockSets.get(key) || [])]), - sadd: jest.fn(async (key, val) => { - if (!mockSets.has(key)) mockSets.set(key, new Set()); - mockSets.get(key).add(val); - }), - srem: jest.fn(async (key, val) => { - mockSets.get(key)?.delete(val); - }), - zadd: jest.fn(async (key, score, member) => { - if (!mockZSets.has(key)) mockZSets.set(key, new Map()); - mockZSets.get(key).set(member, Number(score)); - }), - zrem: jest.fn(async (key, ...members) => { - const z = mockZSets.get(key); - if (!z) return; - for (const m of members) z.delete(m); - }), - zrevrange: jest.fn(async (key, start, stop) => { - const z = mockZSets.get(key); - if (!z) return []; - const sorted = [...z.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m); - const end = stop === -1 ? sorted.length : stop + 1; - return sorted.slice(start, end); - }), - zcard: jest.fn(async (key) => (mockZSets.get(key)?.size || 0)), - zscan: jest.fn(async (key, cursor, _countKeyword, count) => { - const entries = [...(mockZSets.get(key)?.entries() || [])]; - const batchWithScores = []; - const start = Number(cursor); - for (let i = start; i < start + count && i < entries.length; i += 1) { - batchWithScores.push(entries[i][0], entries[i][1]); - } - const nextCursor = start + count >= entries.length ? '0' : String(start + count); - return [nextCursor, batchWithScores]; - }), - llen: jest.fn(async (key) => (mockLists.get(key) || []).length), - lpush: jest.fn(async (key, ...vals) => { - if (!mockLists.has(key)) mockLists.set(key, []); - mockLists.get(key).unshift(...vals); - }), - rpush: jest.fn(async (key, ...vals) => { - if (!mockLists.has(key)) mockLists.set(key, []); - mockLists.get(key).push(...vals); - }), - lrange: jest.fn(async (key, start, end) => { - const list = mockLists.get(key) || []; - const startIdx = start === -1 ? list.length + start : start; - const endIdx = end === -1 ? list.length + end : end; - return list.slice(startIdx, endIdx + 1); - }), - incr: jest.fn(async (key) => { - const count = (mockCounters.get(key) || 0) + 1; - mockCounters.set(key, count); - return count; - }), - expire: jest.fn(async () => 1), -}; - -jest.mock('../src/services/cache', () => ({ - getClient: () => mockRedis, - get: jest.fn(async (key) => { - const v = mockStore.get(key); - return v !== undefined ? JSON.parse(JSON.stringify(v)) : null; - }), - set: jest.fn(async (key, value) => { - mockStore.set(key, JSON.parse(JSON.stringify(value))); - }), - del: jest.fn(async (key) => { - mockStore.delete(key); - mockLists.delete(key); - }), -})); - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -const mockLedger = { sequence: 12345 }; -jest.mock('stellar-sdk', () => ({ - Horizon: { - Server: jest.fn(() => ({ - ledgers: jest.fn(() => ({ - order: jest.fn(() => ({ - limit: jest.fn(() => ({ - call: jest.fn(async () => ({ records: [mockLedger] })), - })), - })), - })), - })), - }, - StrKey: { - isValidEd25519PublicKey: jest.fn((address) => address.startsWith('G') && address.length === 56), - }, - SorobanRpc: { - Server: jest.fn(() => ({})), - }, -})); - -const request = require('supertest'); -const cache = require('../src/services/cache'); -const config = require('../src/config'); -let app; - -beforeAll(() => { - app = require('../src/index').app; -}); - -beforeEach(() => { - mockStore.clear(); - mockSets.clear(); - mockZSets.clear(); - mockLists.clear(); - mockCounters.clear(); - cache.get.mockClear(); - cache.set.mockClear(); - cache.del.mockClear(); - mockRedis.smembers.mockClear(); - mockRedis.sadd.mockClear(); - mockRedis.srem.mockClear(); - mockRedis.zadd.mockClear(); - mockRedis.zrem.mockClear(); - mockRedis.zcard.mockClear(); - mockRedis.zrevrange.mockClear(); - mockRedis.zrevrange.mockClear(); - mockRedis.zcard.mockClear(); - mockRedis.zscan.mockClear(); - mockRedis.llen.mockClear(); - mockRedis.lpush.mockClear(); - mockRedis.rpush.mockClear(); - mockRedis.lrange.mockClear(); - mockRedis.incr.mockClear(); - mockRedis.expire.mockClear(); -}); - -const validAddress1 = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; -const validAddress2 = 'GDRREYWHQWJDICNH4SAH4TT2JPVYWIX6JEWAHE2W6BZDJBIJ4VSX227Z'; - -describe('POST /api/v1/airdrops', () => { - test('creates airdrop successfully', async () => { - const response = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - description: 'Test Description', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, // Greater than mockLedger.sequence (12345) - recipients: [ - { address: validAddress1, amount: 50 }, - { address: validAddress2, amount: 50 }, - ], - }); - expect(response.status).toBe(201); - expect(response.body.id).toMatch(/^drop_/); - expect(response.body.name).toBe('Test Airdrop'); - }); - - test('returns validation error for invalid Stellar address', async () => { - const response = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - asset: 'USDC', - asset_issuer: 'invalid', - total_amount: 100, - expiry_ledger: 123456, - }); - expect(response.status).toBe(400); - expect(response.body.error.code).toBe('VALIDATION_ERROR'); - }); - - test('returns validation error when sum of recipients does not equal total_amount', async () => { - const response = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - recipients: [{ address: validAddress1, amount: 50 }], - }); - expect(response.status).toBe(400); - expect(response.body.error).toMatchObject({ - code: 'VALIDATION_ERROR', - message: 'Validation failed', - }); - expect(response.body.error.details.fields.recipients).toEqual( - expect.arrayContaining([expect.stringContaining('sum of recipient amounts')]) - ); - }); - - test('rate limits repeated airdrop creation attempts', async () => { - for (let i = 0; i < config.airdrops.rateLimit.max; i += 1) { - const response = await request(app).post('/api/v1/airdrops').send({}); - expect(response.status).toBe(400); - } - - const blocked = await request(app).post('/api/v1/airdrops').send({}); - expect(blocked.status).toBe(429); - expect(blocked.body.error.code).toBe('RATE_LIMITED'); - }); -}); - -describe('GET /api/v1/airdrops', () => { - test('lists airdrops with pagination', async () => { - const res1 = await request(app) - await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Airdrop 1', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - - const res2 = await request(app) - await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Airdrop 2', - asset: 'XLM', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 200, - expiry_ledger: 123457, - }); - - const response = await request(app).get('/api/v1/airdrops?page=1&limit=2'); - expect(response.status).toBe(200); - // Canonical pagination envelope (#131): array under `data`, not `airdrops`. - expect(response.body.data).toHaveLength(2); - expect(response.body.pagination.total).toBe(2); - expect(response.body.pagination.has_next).toBe(false); - expect(response.body.pagination.has_prev).toBe(false); - }); -}); - -describe('GET /api/v1/airdrops/:id', () => { - test('returns airdrop by id', async () => { - const createResponse = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - - const getResponse = await request(app).get(`/api/v1/airdrops/${createResponse.body.id}`); - expect(getResponse.status).toBe(200); - expect(getResponse.body.id).toBe(createResponse.body.id); - }); - - test('returns 404 for non-existent airdrop', async () => { - const response = await request(app).get('/api/v1/airdrops/drop_nonexistent'); - expect(response.status).toBe(404); - }); -}); - -describe('PATCH /api/v1/airdrops/:id', () => { - test('updates airdrop successfully', async () => { - const createResponse = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - - const updateResponse = await request(app) - .patch(`/api/v1/airdrops/${createResponse.body.id}`) - .send({ name: 'Updated Airdrop', description: 'Updated Description' }); - - expect(updateResponse.status).toBe(200); - expect(updateResponse.body.name).toBe('Updated Airdrop'); - expect(updateResponse.body.description).toBe('Updated Description'); - }); -}); - -describe('DELETE /api/v1/airdrops/:id', () => { - test('deletes airdrop successfully', async () => { - const createResponse = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - - const deleteResponse = await request(app).delete(`/api/v1/airdrops/${createResponse.body.id}`); - expect(deleteResponse.status).toBe(200); - expect(deleteResponse.body.deleted).toBe(true); - }); -}); - -describe('POST /api/v1/airdrops/:id/cancel', () => { - test('cancels airdrop successfully', async () => { - const createResponse = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - - const cancelResponse = await request(app).post(`/api/v1/airdrops/${createResponse.body.id}/cancel`); - expect(cancelResponse.status).toBe(200); - expect(cancelResponse.body.status).toBe('cancelled'); - }); - - test('idempotent cancellation', async () => { - const createResponse = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - - await request(app).post(`/api/v1/airdrops/${createResponse.body.id}/cancel`); - const secondCancelResponse = await request(app).post(`/api/v1/airdrops/${createResponse.body.id}/cancel`); - expect(secondCancelResponse.status).toBe(200); - }); -}); - -describe('POST /api/v1/airdrops/:id/recipients', () => { - test('adds recipients successfully', async () => { - const createResponse = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - - const addResponse = await request(app) - .post(`/api/v1/airdrops/${createResponse.body.id}/recipients`) - .send({ recipients: [{ address: validAddress1, amount: 50 }] }); - - expect(addResponse.status).toBe(201); - expect(addResponse.body.added).toBe(1); - }); - - test('parses CSV file successfully', async () => { - const createResponse = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - - const csvContent = 'address,amount\n' + validAddress1 + ',50\n' + validAddress2 + ',50'; - const addResponse = await request(app) - .post(`/api/v1/airdrops/${createResponse.body.id}/recipients`) - .attach('file', Buffer.from(csvContent), 'recipients.csv'); - - expect(addResponse.status).toBe(201); - expect(addResponse.body.added).toBe(2); - }); - - test('rejects a CSV larger than the configured upload limit', async () => { - const createResponse = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - - const oversized = Buffer.alloc(config.airdrops.csvMaxBytes + 1, 'a'); - const response = await request(app) - .post(`/api/v1/airdrops/${createResponse.body.id}/recipients`) - .attach('file', oversized, 'recipients.csv'); - - expect(response.status).toBe(413); - expect(response.body.error).toMatchObject({ - code: 'PAYLOAD_TOO_LARGE', - details: { max_bytes: config.airdrops.csvMaxBytes }, - }); - expect(mockRedis.rpush).not.toHaveBeenCalled(); - }); - - test('stops CSV parsing when the 10,000-row limit is crossed', async () => { - const createResponse = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - const row = `${validAddress1},1\n`; - const csvContent = `address,amount\n${row.repeat(10001)}`; - - const response = await request(app) - .post(`/api/v1/airdrops/${createResponse.body.id}/recipients`) - .attach('file', Buffer.from(csvContent), 'recipients.csv'); - - expect(response.status).toBe(400); - expect(response.body.error).toMatchObject({ - code: 'VALIDATION_ERROR', - message: 'recipients cannot exceed 10,000', - }); - expect(mockRedis.rpush).not.toHaveBeenCalled(); - }); - - test('rate limits repeated recipient additions', async () => { - const createResponse = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - const endpoint = `/api/v1/airdrops/${createResponse.body.id}/recipients`; - - for (let i = 0; i < config.airdrops.rateLimit.max; i += 1) { - const response = await request(app) - .post(endpoint) - .send({ recipients: [{ address: validAddress1, amount: 1 }] }); - expect(response.status).toBe(201); - } - - const blocked = await request(app) - .post(endpoint) - .send({ recipients: [{ address: validAddress1, amount: 1 }] }); - expect(blocked.status).toBe(429); - expect(blocked.body.error.code).toBe('RATE_LIMITED'); - }); -}); - -describe('GET /api/v1/airdrops/:id/recipients', () => { - test('lists recipients with pagination', async () => { - const createResponse = await request(app) - .post('/api/v1/airdrops') - .send({ - name: 'Test Airdrop', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - recipients: [ - { address: validAddress1, amount: 50 }, - { address: validAddress2, amount: 50 }, - ], - }); - - const listResponse = await request(app).get(`/api/v1/airdrops/${createResponse.body.id}/recipients`); - expect(listResponse.status).toBe(200); - // Canonical pagination envelope (#131): array under `data`, not `recipients`. - expect(listResponse.body.data).toHaveLength(2); - expect(listResponse.body.pagination.total).toBe(2); - }); -}); diff --git a/test/alerts-routes.test.js b/test/alerts-routes.test.js deleted file mode 100644 index 0ff9401..0000000 --- a/test/alerts-routes.test.js +++ /dev/null @@ -1,83 +0,0 @@ -'use strict'; - -const adminApiKey = 'a'.repeat(64); -process.env.ADMIN_API_KEY = adminApiKey; - -const mockStore = new Map(); -const mockSortedSets = new Map(); - -const mockRedis = { - smembers: jest.fn(async () => []), - zadd: jest.fn(async (key, score, member) => { - if (!mockSortedSets.has(key)) mockSortedSets.set(key, new Map()); - mockSortedSets.get(key).set(member, score); - }), - zrem: jest.fn(async (key, member) => { - mockSortedSets.get(key)?.delete(member); - }), - zcard: jest.fn(async (key) => mockSortedSets.get(key)?.size || 0), - zrevrange: jest.fn(async (key, start, stop) => { - const sortedSet = mockSortedSets.get(key); - if (!sortedSet) return []; - const entries = Array.from(sortedSet.entries()).sort((a, b) => b[1] - a[1]); - const startIdx = start === -1 ? entries.length + start : start; - const stopIdx = stop === -1 ? entries.length + stop : stop; - return entries.slice(startIdx, stopIdx + 1).map(([member]) => member); - }), -}; - -jest.mock('../src/services/cache', () => ({ - getClient: () => mockRedis, - get: jest.fn(async (key) => mockStore.get(key) || null), - set: jest.fn(async (key, value) => mockStore.set(key, value)), - del: jest.fn(async (key) => mockStore.delete(key)), - disconnect: jest.fn(), - isConnected: jest.fn(() => false), -})); - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -})); - -const request = require('supertest'); - -const { app, server } = require('../src'); -const priceRefreshJob = require('../src/jobs/priceRefresh'); - -beforeEach(() => { - mockStore.clear(); - mockSortedSets.clear(); -}); - - -describe('GET /api/v1/alerts pagination', () => { - - afterAll((done) => { - priceRefreshJob.stop(); - if (server) server.close(done); - else done(); - }); - - - test('returns pagination envelope', async () => { - - const response = await request(app) - .get('/api/v1/alerts') - .set('Authorization', `Bearer ${adminApiKey}`); - - - expect(response.statusCode).toBe(200); - - expect(response.body).toHaveProperty('data'); - - expect(response.body).toHaveProperty('pagination'); - - expect(response.body.pagination).toHaveProperty('page'); - - expect(response.body.pagination).toHaveProperty('limit'); - - }); - -}); diff --git a/test/alerts.test.js b/test/alerts.test.js deleted file mode 100644 index 3bd9a07..0000000 --- a/test/alerts.test.js +++ /dev/null @@ -1,256 +0,0 @@ -'use strict'; - -const mockStore = new Map(); -const mockSets = new Map(); -const mockZSets = new Map(); - -function getSortedZSetMembers(key) { - const z = mockZSets.get(key); - if (!z) return []; - return [...z.entries()] - .sort((a, b) => b[1] - a[1]) - .map(([member]) => member); -} - -const mockRedis = { - smembers: jest.fn(async (key) => [...(mockSets.get(key) || [])]), - sadd: jest.fn(async (key, val) => { if (!mockSets.has(key)) mockSets.set(key, new Set()); mockSets.get(key).add(val); }), - srem: jest.fn(async (key, val) => { mockSets.get(key)?.delete(val); }), - zadd: jest.fn(async (key, score, member) => { - if (!mockZSets.has(key)) mockZSets.set(key, new Map()); - mockZSets.get(key).set(member, Number(score)); - }), - zrem: jest.fn(async (key, ...members) => { - const z = mockZSets.get(key); - if (!z) return; - for (const m of members) z.delete(m); - }), - zrevrange: jest.fn(async (key, start, stop) => { - const sorted = getSortedZSetMembers(key); - const end = stop === -1 ? sorted.length : stop + 1; - return sorted.slice(start, end); - }), - zcard: jest.fn(async (key) => (mockZSets.get(key)?.size || 0)), -}; - -jest.mock('../src/services/cache', () => ({ - getClient: () => mockRedis, - get: jest.fn(async (key) => { - const v = mockStore.get(key); - return v !== undefined ? JSON.parse(JSON.stringify(v)) : null; - }), - set: jest.fn(async (key, value) => { mockStore.set(key, JSON.parse(JSON.stringify(value))); }), - del: jest.fn(async (key) => { mockStore.delete(key); }), -})); - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -const mockWebhookDeliver = jest.fn(async () => {}); -jest.mock('../src/services/webhook', () => ({ deliver: mockWebhookDeliver })); - -const alertsService = require('../src/services/alerts'); -const cache = require('../src/services/cache'); - -beforeEach(() => { - mockStore.clear(); - mockSets.clear(); - mockZSets.clear(); - mockWebhookDeliver.mockClear(); - cache.get.mockClear(); - cache.set.mockClear(); - cache.del.mockClear(); - mockRedis.smembers.mockClear(); - mockRedis.sadd.mockClear(); - mockRedis.srem.mockClear(); -}); - -async function makeAlert(overrides = {}) { - return alertsService.create({ - asset: 'XLM', - type: 'below', - threshold_usd: 0.09, - webhook_url: 'https://example.com/hook', - webhook_secret: 'whsec_testsecret', - repeat: false, - ...overrides, - }); -} - -describe('alert creation', () => { - test('returns alert with generated id and normalised asset', async () => { - const alert = await makeAlert(); - expect(alert.id).toMatch(/^alrt_/); - expect(alert.asset).toBe('XLM'); - expect(alert.type).toBe('below'); - expect(alert.repeat).toBe(false); - expect(alert.last_fired_at).toBeNull(); - }); - - test('sets baseline_price from cache for change_pct type', async () => { - mockStore.set('price:XLM', { price: 0.12 }); - const alert = await makeAlert({ type: 'change_pct', threshold_usd: 10 }); - expect(alert.baseline_price).toBe(0.12); - }); - - test('baseline_price is null when no cached price exists for change_pct', async () => { - const alert = await makeAlert({ type: 'change_pct', threshold_usd: 10 }); - expect(alert.baseline_price).toBeNull(); - }); -}); - -describe('below alert', () => { - test('fires when price is below threshold', async () => { - await makeAlert({ threshold_usd: 0.09 }); - await alertsService.evaluateForAsset('XLM', 0.087); - expect(mockWebhookDeliver).toHaveBeenCalledTimes(1); - const payload = mockWebhookDeliver.mock.calls[0][2]; - expect(payload.event).toBe('price.alert'); - expect(payload.type).toBe('below'); - expect(payload.actual_price_usd).toBe(0.087); - }); - - test('does not fire when price is above threshold', async () => { - await makeAlert({ threshold_usd: 0.09 }); - await alertsService.evaluateForAsset('XLM', 0.10); - expect(mockWebhookDeliver).not.toHaveBeenCalled(); - }); - - test('does not fire when price equals threshold', async () => { - await makeAlert({ threshold_usd: 0.09 }); - await alertsService.evaluateForAsset('XLM', 0.09); - expect(mockWebhookDeliver).not.toHaveBeenCalled(); - }); -}); - -describe('above alert', () => { - test('fires when price is above threshold', async () => { - await makeAlert({ type: 'above', threshold_usd: 0.15 }); - await alertsService.evaluateForAsset('XLM', 0.16); - expect(mockWebhookDeliver).toHaveBeenCalledTimes(1); - }); - - test('does not fire when price is below threshold', async () => { - await makeAlert({ type: 'above', threshold_usd: 0.15 }); - await alertsService.evaluateForAsset('XLM', 0.14); - expect(mockWebhookDeliver).not.toHaveBeenCalled(); - }); -}); - -describe('change_pct alert', () => { - test('fires when price changes by >= threshold percent', async () => { - mockStore.set('price:XLM', { price: 0.10 }); - await makeAlert({ type: 'change_pct', threshold_usd: 10 }); - await alertsService.evaluateForAsset('XLM', 0.111); - expect(mockWebhookDeliver).toHaveBeenCalledTimes(1); - }); - - test('does not fire when change is below threshold percent', async () => { - mockStore.set('price:XLM', { price: 0.10 }); - await makeAlert({ type: 'change_pct', threshold_usd: 10 }); - await alertsService.evaluateForAsset('XLM', 0.105); - expect(mockWebhookDeliver).not.toHaveBeenCalled(); - }); - - test('does not fire when baseline_price is null', async () => { - await makeAlert({ type: 'change_pct', threshold_usd: 5 }); - await alertsService.evaluateForAsset('XLM', 0.20); - expect(mockWebhookDeliver).not.toHaveBeenCalled(); - }); -}); - -describe('repeat: false', () => { - test('alert is deleted after firing', async () => { - await makeAlert({ repeat: false, threshold_usd: 0.09 }); - await alertsService.evaluateForAsset('XLM', 0.08); - expect(mockWebhookDeliver).toHaveBeenCalledTimes(1); - - const remaining = await alertsService.list(); - expect(remaining).toHaveLength(0); - }); -}); - -describe('repeat: true with cooldown', () => { - test('fires on first trigger', async () => { - await makeAlert({ repeat: true, threshold_usd: 0.09 }); - await alertsService.evaluateForAsset('XLM', 0.08); - expect(mockWebhookDeliver).toHaveBeenCalledTimes(1); - }); - - test('does not re-fire within 5-minute cooldown', async () => { - await makeAlert({ repeat: true, threshold_usd: 0.09 }); - - await alertsService.evaluateForAsset('XLM', 0.08); - expect(mockWebhookDeliver).toHaveBeenCalledTimes(1); - - await alertsService.evaluateForAsset('XLM', 0.07); - expect(mockWebhookDeliver).toHaveBeenCalledTimes(1); - }); - - test('alert remains in list after firing', async () => { - await makeAlert({ repeat: true, threshold_usd: 0.09 }); - await alertsService.evaluateForAsset('XLM', 0.08); - const remaining = await alertsService.list(); - expect(remaining).toHaveLength(1); - expect(remaining[0].last_fired_at).not.toBeNull(); - }); - - test('fires again after cooldown expires', async () => { - await makeAlert({ repeat: true, threshold_usd: 0.09 }); - - await alertsService.evaluateForAsset('XLM', 0.08); - expect(mockWebhookDeliver).toHaveBeenCalledTimes(1); - - // Backdate last_fired_at by 6 minutes - const [alert] = await alertsService.list(); - const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000).toISOString(); - mockStore.set(`alert:${alert.id}`, { ...alert, last_fired_at: sixMinutesAgo }); - - await alertsService.evaluateForAsset('XLM', 0.07); - expect(mockWebhookDeliver).toHaveBeenCalledTimes(2); - }); -}); - -describe('CRUD via service', () => { - test('list returns empty array initially', async () => { - const alerts = await alertsService.list(); - expect(alerts).toHaveLength(0); - }); - - test('list returns all created alerts', async () => { - await makeAlert(); - await makeAlert({ type: 'above', threshold_usd: 0.15 }); - const alerts = await alertsService.list(); - expect(alerts).toHaveLength(2); - }); - - test('remove deletes alert and returns it', async () => { - const alert = await makeAlert(); - const deleted = await alertsService.remove(alert.id); - expect(deleted.id).toBe(alert.id); - expect(await alertsService.list()).toHaveLength(0); - }); - - test('remove returns null for unknown id', async () => { - expect(await alertsService.remove('alrt_nonexistent')).toBeNull(); - }); -}); - -describe('evaluateAll', () => { - test('evaluates alerts using current price from cache', async () => { - mockStore.set('price:XLM', { price: 0.08 }); - await makeAlert({ threshold_usd: 0.09 }); - await alertsService.evaluateAll(); - expect(mockWebhookDeliver).toHaveBeenCalledTimes(1); - }); - - test('skips assets with no cached price', async () => { - await makeAlert({ threshold_usd: 0.09 }); - await alertsService.evaluateAll(); - expect(mockWebhookDeliver).not.toHaveBeenCalled(); - }); -}); diff --git a/test/api-docs.test.js b/test/api-docs.test.js deleted file mode 100644 index ff5767c..0000000 --- a/test/api-docs.test.js +++ /dev/null @@ -1,97 +0,0 @@ -'use strict'; - -const path = require('path'); -const fs = require('fs'); -const express = require('express'); -const request = require('supertest'); - -describe('OpenAPI specification', () => { - test('openapi.yaml exists and is valid YAML', () => { - const specPath = path.join(__dirname, '..', 'openapi.yaml'); - expect(fs.existsSync(specPath)).toBe(true); - - const content = fs.readFileSync(specPath, 'utf8'); - expect(content).toContain('openapi: 3.0.3'); - expect(content).toContain('SmartDrop API'); - }); - - test('spec defines all required endpoints from the issue', () => { - const specPath = path.join(__dirname, '..', 'openapi.yaml'); - const content = fs.readFileSync(specPath, 'utf8'); - - expect(content).toContain('/health'); - expect(content).toContain('/api/v1/prices/{asset_code}'); - expect(content).toContain('/api/v1/prices/batch'); - expect(content).toContain('/api/v1/webhooks'); - expect(content).toContain('/api/v1/indexer/status'); - expect(content).toContain('/ws'); - expect(content).toContain('x-draft: true'); - }); - - test('spec includes Bearer security scheme', () => { - const specPath = path.join(__dirname, '..', 'openapi.yaml'); - const content = fs.readFileSync(specPath, 'utf8'); - - expect(content).toContain('BearerAuth'); - expect(content).toContain('bearer'); - }); - - test('spec includes all required error responses', () => { - const specPath = path.join(__dirname, '..', 'openapi.yaml'); - const content = fs.readFileSync(specPath, 'utf8'); - - expect(content).toContain('ValidationError'); - expect(content).toContain('Unauthorized'); - expect(content).toContain('NotFound'); - expect(content).toContain('UnprocessableEntity'); - expect(content).toContain('RateLimited'); - expect(content).toContain('InternalError'); - }); -}); - -describe('Swagger UI', () => { - let app; - - beforeAll(() => { - jest.isolateModules(() => { - const apiDocsRouter = require('../src/routes/apiDocs'); - app = express(); - app.use('/api-docs', apiDocsRouter); - }); - }); - - test('GET /api-docs/openapi.yaml serves the spec file', async () => { - const res = await request(app).get('/api-docs/openapi.yaml'); - - expect(res.status).toBe(200); - expect(res.headers['content-type']).toMatch(/yaml/); - expect(res.text).toContain('openapi: 3.0.3'); - }); - - test('GET /api-docs/openapi.yaml matches the file on disk', async () => { - const res = await request(app).get('/api-docs/openapi.yaml'); - const specPath = path.join(__dirname, '..', 'openapi.yaml'); - const fileContent = fs.readFileSync(specPath, 'utf8'); - - expect(res.text).toBe(fileContent); - }); - - test('Swagger UI HTML is served at /api-docs in development mode', () => { - const NODE_ENV = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; - - jest.resetModules(); - const devRouter = require('../src/routes/apiDocs'); - const devApp = express(); - devApp.use('/api-docs', devRouter); - - return request(devApp) - .get('/api-docs/') - .expect(200) - .then((res) => { - expect(res.text).toContain('swagger-ui'); - expect(res.text).toContain('SmartDrop API Docs'); - process.env.NODE_ENV = NODE_ENV; - }); - }); -}); diff --git a/test/apiRateLimit.test.js b/test/apiRateLimit.test.js deleted file mode 100644 index f890239..0000000 --- a/test/apiRateLimit.test.js +++ /dev/null @@ -1,97 +0,0 @@ -'use strict'; - -const express = require('express'); -const request = require('supertest'); -const { createCacheMock } = require('./helpers/cacheMock'); - -const mockHelper = createCacheMock(); -const { reset } = mockHelper; - -jest.mock('../src/services/cache', () => mockHelper.cacheMock); -jest.mock('../src/logger', () => ({ - info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), -})); - -const mockGetPrice = jest.fn(); -jest.mock('../src/services/priceOracle', () => ({ - getPrice: mockGetPrice, - fetchFreshPrice: jest.fn(), -})); - -const buildRateLimit = require('../src/middleware/rateLimit'); -const pricesRouter = require('../src/routes/prices'); -const { errorHandler } = require('../src/middleware/errorHandler'); - -function priceResponse() { - return { - asset_code: 'XLM', - issuer: null, - price_usd: 0.12, - source: 'coingecko', - fetched_at: '2026-06-25T00:00:00.000Z', - is_stale: false, - stale_warning: null, - sources_attempted: ['coingecko'], - redis_unavailable: false, - }; -} - -function buildApiApp({ globalMax = 100, globalWindowSeconds = 60 } = {}) { - const app = express(); - app.use(express.json()); - app.use('/api/v1', buildRateLimit({ - windowSeconds: globalWindowSeconds, - max: globalMax, - keyPrefix: 'api', - })); - app.use('/api/v1', pricesRouter); - app.use(errorHandler); - return app; -} - -beforeEach(() => { - reset(); - mockGetPrice.mockReset(); - mockGetPrice.mockResolvedValue(priceResponse()); -}); - -describe('API rate limiting integration', () => { - test('global limit returns 429 after max requests per IP', async () => { - const app = buildApiApp({ globalMax: 2, globalWindowSeconds: 60 }); - - await request(app).get('/api/v1/prices/XLM'); - await request(app).get('/api/v1/prices/XLM'); - const blocked = await request(app).get('/api/v1/prices/XLM'); - - expect(blocked.status).toBe(429); - expect(blocked.body.error.code).toBe('RATE_LIMITED'); - expect(blocked.body.error.details.retry_after_seconds).toBeGreaterThan(0); - expect(blocked.headers['x-ratelimit-limit']).toBe('2'); - expect(blocked.headers['retry-after']).toBeDefined(); - }); - - test('prices routes enforce stricter 30 req/min limit', async () => { - const app = buildApiApp({ globalMax: 100, globalWindowSeconds: 60 }); - - for (let i = 0; i < 30; i += 1) { - const res = await request(app).get('/api/v1/prices/XLM'); - expect(res.status).toBe(200); - expect(res.headers['x-ratelimit-limit']).toBe('30'); - } - - const blocked = await request(app).get('/api/v1/prices/XLM'); - expect(blocked.status).toBe(429); - expect(blocked.body.error.code).toBe('RATE_LIMITED'); - expect(blocked.headers['x-ratelimit-limit']).toBe('30'); - }); - - test('successful responses include rate-limit headers', async () => { - const app = buildApiApp({ globalMax: 100, globalWindowSeconds: 60 }); - const res = await request(app).get('/api/v1/prices/XLM'); - - expect(res.status).toBe(200); - expect(res.headers['x-ratelimit-limit']).toBe('30'); - expect(res.headers['x-ratelimit-remaining']).toBeDefined(); - expect(res.headers['x-ratelimit-reset']).toBeDefined(); - }); -}); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts new file mode 100644 index 0000000..2232612 --- /dev/null +++ b/test/app.e2e-spec.ts @@ -0,0 +1,32 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from './../src/app.module'; + +// Requires a real DATABASE_URL (and the rest of .env.example) — PrismaService +// connects on module init, so this is a genuine end-to-end check, not run in +// CI yet since no Postgres/Soroban RPC service is provisioned there. +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/health (GET)', () => { + return request(app.getHttpServer()) + .get('/health') + .expect(200) + .expect({ status: 'ok', service: 'stellar-tickets-backend' }); + }); + + afterEach(async () => { + await app.close(); + }); +}); diff --git a/test/auth.test.js b/test/auth.test.js deleted file mode 100644 index c432e3e..0000000 --- a/test/auth.test.js +++ /dev/null @@ -1,237 +0,0 @@ -'use strict'; - -process.env.ADMIN_API_KEY = 'a'.repeat(64); - -const crypto = require('crypto'); - -const mockStore = new Map(); -const mockSets = new Map(); -const mockSortedSets = new Map(); - -const mockRedis = { - smembers: jest.fn(async (key) => [...(mockSets.get(key) || [])]), - sadd: jest.fn(async (key, val) => { - if (!mockSets.has(key)) mockSets.set(key, new Set()); - mockSets.get(key).add(val); - }), - srem: jest.fn(async (key, val) => { - mockSets.get(key)?.delete(val); - }), - zadd: jest.fn(async (key, score, member) => { - if (!mockSortedSets.has(key)) mockSortedSets.set(key, new Map()); - mockSortedSets.get(key).set(member, score); - }), - zrem: jest.fn(async (key, member) => { - mockSortedSets.get(key)?.delete(member); - }), - zrevrange: jest.fn(async (key, start, stop) => { - const sortedSet = mockSortedSets.get(key); - if (!sortedSet) return []; - const entries = Array.from(sortedSet.entries()).sort((a, b) => b[1] - a[1]); - const startIdx = start === -1 ? entries.length + start : start; - const stopIdx = stop === -1 ? entries.length + stop : stop; - return entries.slice(startIdx, stopIdx + 1).map(([member]) => member); - }), -}; - -jest.mock('../src/services/cache', () => ({ - getClient: () => mockRedis, - get: jest.fn(async (key) => { - const v = mockStore.get(key); - return v !== undefined ? JSON.parse(JSON.stringify(v)) : null; - }), - set: jest.fn(async (key, value) => { - mockStore.set(key, JSON.parse(JSON.stringify(value))); - }), - del: jest.fn(async (key) => { - mockStore.delete(key); - }), -})); - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -const express = require('express'); -const request = require('supertest'); -const { requireApiKey } = require('../src/middleware/auth'); -const keysRouter = require('../src/routes/keys'); -const apiKeys = require('../src/services/apiKeys'); -const cache = require('../src/services/cache'); -const { errorHandler } = require('../src/middleware/errorHandler'); - -function buildProtectedApp(options) { - const app = express(); - app.use(express.json()); - app.get('/protected', requireApiKey(options), (req, res) => { - res.json({ ok: true, key: req.apiKey }); - }); - app.use(errorHandler); - return app; -} - -function buildKeysApp() { - const app = express(); - app.use(express.json()); - app.use('/api/v1', keysRouter); - app.use(errorHandler); - return app; -} - -beforeEach(() => { - mockStore.clear(); - mockSets.clear(); - mockSortedSets.clear(); - cache.get.mockClear(); - cache.set.mockClear(); - cache.del.mockClear(); - mockRedis.smembers.mockClear(); - mockRedis.sadd.mockClear(); - mockRedis.srem.mockClear(); - mockRedis.zadd.mockClear(); - mockRedis.zrem.mockClear(); - mockRedis.zrevrange.mockClear(); -}); - -describe('requireApiKey middleware', () => { - test('missing API key returns consistent 401 body', async () => { - const app = buildProtectedApp(); - const res = await request(app).get('/protected'); - - expect(res.status).toBe(401); - expect(res.body.error).toMatchObject({ code: 'UNAUTHORIZED', message: 'Missing or invalid API key' }); - }); - - test('invalid API key returns consistent 401 body', async () => { - const app = buildProtectedApp(); - const res = await request(app) - .get('/protected') - .set('Authorization', 'Bearer bad-key'); - - expect(res.status).toBe(401); - expect(res.body.error).toMatchObject({ code: 'UNAUTHORIZED', message: 'Missing or invalid API key' }); - }); - - test('ADMIN_API_KEY authenticates bootstrap admin requests', async () => { - const app = buildProtectedApp({ scopes: ['admin'] }); - const res = await request(app) - .get('/protected') - .set('Authorization', `Bearer ${process.env.ADMIN_API_KEY}`); - - expect(res.status).toBe(200); - expect(res.body.key.id).toBe('admin'); - expect(res.body.key.scopes).toContain('admin'); - }); - - test('ADMIN_API_KEY comparison uses timingSafeEqual on fixed-length digests', async () => { - const timingSpy = jest.spyOn(crypto, 'timingSafeEqual'); - - try { - const result = await apiKeys.validateApiKey(process.env.ADMIN_API_KEY); - - expect(result.id).toBe('admin'); - expect(timingSpy).toHaveBeenCalledTimes(1); - const [actualDigest, expectedDigest] = timingSpy.mock.calls[0]; - expect(Buffer.isBuffer(actualDigest)).toBe(true); - expect(Buffer.isBuffer(expectedDigest)).toBe(true); - expect(actualDigest).toHaveLength(32); - expect(expectedDigest).toHaveLength(32); - } finally { - timingSpy.mockRestore(); - } - }); - - test('wrong-length admin API key guesses do not throw before constant-time comparison', async () => { - const timingSpy = jest.spyOn(crypto, 'timingSafeEqual'); - - try { - await expect(apiKeys.validateApiKey('short')).resolves.toBeNull(); - - expect(timingSpy).toHaveBeenCalledTimes(1); - const [actualDigest, expectedDigest] = timingSpy.mock.calls[0]; - expect(actualDigest).toHaveLength(32); - expect(expectedDigest).toHaveLength(32); - } finally { - timingSpy.mockRestore(); - } - }); - - test('generated API key authenticates and updates last_used_at', async () => { - const created = await apiKeys.createKey({ label: 'alerts worker', scopes: ['alerts'] }); - const app = buildProtectedApp(); - - const res = await request(app) - .get('/protected') - .set('Authorization', `Bearer ${created.api_key}`); - - expect(res.status).toBe(200); - const stored = await apiKeys.getKey(created.key.id); - expect(stored.last_used_at).toEqual(expect.any(String)); - }); -}); - -describe('API key management routes', () => { - test('admin can create, list, and revoke API keys without persisting raw key', async () => { - const app = buildKeysApp(); - - const createRes = await request(app) - .post('/api/v1/keys') - .set('Authorization', `Bearer ${process.env.ADMIN_API_KEY}`) - .send({ label: 'alerts worker', scopes: ['alerts'] }); - - expect(createRes.status).toBe(201); - expect(createRes.body.api_key).toMatch(/^[a-f0-9]{64}$/); - expect(createRes.body.key).toMatchObject({ - label: 'alerts worker', - scopes: ['alerts'], - last_used_at: null, - }); - expect(createRes.body.key.key_hash).toBeUndefined(); - - const stored = [...mockStore.values()].map((value) => JSON.stringify(value)).join('\n'); - expect(stored).not.toContain(createRes.body.api_key); - expect(stored).toContain(apiKeys.hashApiKey(createRes.body.api_key)); - - const listRes = await request(app) - .get('/api/v1/keys') - .set('Authorization', `Bearer ${process.env.ADMIN_API_KEY}`); - - expect(listRes.status).toBe(200); - expect(listRes.body.keys).toHaveLength(1); - expect(listRes.body.keys[0].key_hash).toBeUndefined(); - - const deleteRes = await request(app) - .delete(`/api/v1/keys/${createRes.body.key.id}`) - .set('Authorization', `Bearer ${process.env.ADMIN_API_KEY}`); - - expect(deleteRes.status).toBe(200); - expect(deleteRes.body.deleted).toBe(true); - expect(await apiKeys.getKey(createRes.body.key.id)).toBeNull(); - }); - - test('key management routes require admin API key', async () => { - const app = buildKeysApp(); - const res = await request(app).get('/api/v1/keys'); - - expect(res.status).toBe(401); - expect(res.body.error).toMatchObject({ code: 'UNAUTHORIZED', message: 'Missing or invalid API key' }); - }); - - test('create key rejects blank labels', async () => { - const app = buildKeysApp(); - const res = await request(app) - .post('/api/v1/keys') - .set('Authorization', `Bearer ${process.env.ADMIN_API_KEY}`) - .send({ label: ' ' }); - - expect(res.status).toBe(400); - expect(res.body.error).toMatchObject({ - code: 'VALIDATION_ERROR', - message: 'Validation failed', - }); - expect(res.body.error.details.fields.label).toBeDefined(); - }); -}); diff --git a/test/cacheWarm.test.js b/test/cacheWarm.test.js deleted file mode 100644 index 3fdc2ab..0000000 --- a/test/cacheWarm.test.js +++ /dev/null @@ -1,118 +0,0 @@ -'use strict'; - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -jest.mock('../src/services/priceOracle', () => ({ - fetchFreshPrice: jest.fn(), -})); - -const logger = require('../src/logger'); -const priceOracle = require('../src/services/priceOracle'); -const { warmCache } = require('../src/startup/cacheWarm'); - -function asset(code, issuer = null) { - return { code, issuer }; -} - -describe('startup cache warming', () => { - beforeEach(() => { - jest.useRealTimers(); - jest.clearAllMocks(); - }); - - test('skips warming when no assets are configured', async () => { - const summary = await warmCache([], priceOracle, { log: logger }); - - expect(summary).toEqual({ - total: 0, - succeeded: 0, - failed: 0, - timedOut: false, - durationMs: 0, - }); - expect(priceOracle.fetchFreshPrice).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith('Cache warm skipped: no watched assets configured'); - }); - - test('fetches all configured assets and counts cached successes', async () => { - priceOracle.fetchFreshPrice - .mockResolvedValueOnce({ price_usd: 0.12, redis_unavailable: false }) - .mockResolvedValueOnce({ price_usd: 1.0, redis_unavailable: false }) - .mockResolvedValueOnce({ price_usd: null, redis_unavailable: false }); - - const assets = [ - asset('XLM'), - asset('USDC', 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), - asset('BAD'), - ]; - - const summary = await warmCache(assets, priceOracle, { log: logger }); - - expect(priceOracle.fetchFreshPrice).toHaveBeenCalledTimes(3); - expect(priceOracle.fetchFreshPrice).toHaveBeenNthCalledWith(1, 'XLM', null); - expect(priceOracle.fetchFreshPrice).toHaveBeenNthCalledWith( - 2, - 'USDC', - 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - ); - expect(priceOracle.fetchFreshPrice).toHaveBeenNthCalledWith(3, 'BAD', null); - expect(summary).toMatchObject({ total: 3, succeeded: 2, failed: 1, timedOut: false }); - expect(logger.info).toHaveBeenCalledWith('Cache warm complete', expect.objectContaining({ - total: 3, - succeeded: 2, - failed: 1, - timedOut: false, - })); - }); - - test('starts all asset fetches before awaiting settlement', async () => { - let resolveXlm; - let resolveUsdc; - const xlmPromise = new Promise((resolve) => { resolveXlm = resolve; }); - const usdcPromise = new Promise((resolve) => { resolveUsdc = resolve; }); - - priceOracle.fetchFreshPrice - .mockReturnValueOnce(xlmPromise) - .mockReturnValueOnce(usdcPromise); - - const warming = warmCache([asset('XLM'), asset('USDC')], priceOracle, { log: logger }); - await Promise.resolve(); - - expect(priceOracle.fetchFreshPrice).toHaveBeenCalledTimes(2); - - resolveXlm({ price_usd: 0.12, redis_unavailable: false }); - resolveUsdc({ price_usd: 1.0, redis_unavailable: false }); - await expect(warming).resolves.toMatchObject({ succeeded: 2, failed: 0 }); - }); - - test('returns a timeout summary when warming takes too long', async () => { - jest.useFakeTimers(); - priceOracle.fetchFreshPrice.mockReturnValue(new Promise(() => {})); - - const warming = warmCache([asset('XLM')], priceOracle, { - timeoutMs: 25, - log: logger, - }); - - jest.advanceTimersByTime(25); - await expect(warming).resolves.toEqual({ - total: 1, - succeeded: 0, - failed: 1, - timedOut: true, - durationMs: 25, - }); - expect(logger.warn).toHaveBeenCalledWith('Cache warm timed out; starting server anyway', { - total: 1, - succeeded: 0, - failed: 1, - timedOut: true, - durationMs: 25, - }); - }); -}); diff --git a/test/circuitBreaker.test.js b/test/circuitBreaker.test.js deleted file mode 100644 index 5f6b425..0000000 --- a/test/circuitBreaker.test.js +++ /dev/null @@ -1,233 +0,0 @@ -'use strict'; - -const mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -}; - -jest.mock('../src/logger', () => mockLogger); - -const { CircuitBreaker, STATES } = require('../src/utils/circuitBreaker'); - -function buildBreaker(options = {}) { - let now = 1000; - const logger = { - info: jest.fn(), - }; - - const breaker = new CircuitBreaker('coingecko', { - failureThreshold: 2, - successThreshold: 1, - timeoutMs: 100, - now: () => now, - logger, - ...options, - }); - - return { - breaker, - logger, - advance(ms) { - now += ms; - }, - }; -} - -describe('CircuitBreaker', () => { - test('opens after repeated failures and skips calls while cooling down', async () => { - const { breaker, logger } = buildBreaker(); - - await expect(breaker.call(async () => null)).resolves.toBeNull(); - await expect(breaker.call(async () => null)).resolves.toBeNull(); - - expect(breaker.getState()).toBe(STATES.OPEN); - - const sourceFetch = jest.fn(async () => 0.12); - await expect(breaker.call(sourceFetch)).resolves.toBeNull(); - - expect(sourceFetch).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith( - 'Circuit breaker state changed', - expect.objectContaining({ - source: 'coingecko', - from: STATES.CLOSED, - to: STATES.OPEN, - reason: 'failure-threshold', - }) - ); - }); - - test('moves to half-open after cooldown and closes on a successful probe', async () => { - const { breaker, advance } = buildBreaker(); - - await breaker.call(async () => null); - await breaker.call(async () => null); - - advance(100); - expect(breaker.getState()).toBe(STATES.HALF_OPEN); - - await expect(breaker.call(async () => 0.12)).resolves.toBe(0.12); - - expect(breaker.getState()).toBe(STATES.CLOSED); - }); - - test('reopens when the half-open probe fails', async () => { - const { breaker, advance } = buildBreaker(); - - await breaker.call(async () => null); - await breaker.call(async () => null); - - advance(100); - await expect(breaker.call(async () => null)).resolves.toBeNull(); - - expect(breaker.getState()).toBe(STATES.OPEN); - }); - - test('records thrown source errors as failures and rethrows them', async () => { - const { breaker } = buildBreaker(); - const error = new Error('rate limited'); - - await expect(breaker.call(async () => { - throw error; - })).rejects.toThrow('rate limited'); - - expect(breaker.getState()).toBe(STATES.CLOSED); - }); -}); - -function loadCircuitBreaker() { - jest.resetModules(); - mockLogger.error.mockClear(); - mockLogger.warn.mockClear(); - return require('../src/services/sources/circuitBreaker'); -} - -describe('circuit breaker', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - test('starts closed', () => { - const { createCircuitBreaker } = loadCircuitBreaker(); - const circuit = createCircuitBreaker({ - sourceName: 'test-source', - cooldownMs: 60000, - reminderIntervalMs: 30000, - }); - - expect(circuit.isOpen()).toBe(false); - expect(circuit.getState()).toEqual({ source: 'test-source', open: false, openUntil: null }); - }); - - test('open() trips the circuit and logs distinctly at error level the first time', () => { - const { createCircuitBreaker } = loadCircuitBreaker(); - const circuit = createCircuitBreaker({ - sourceName: 'test-source', - cooldownMs: 60000, - reminderIntervalMs: 30000, - }); - - circuit.open({ assetCode: 'XLM' }); - - expect(circuit.isOpen()).toBe(true); - expect(mockLogger.error).toHaveBeenCalledTimes(1); - expect(mockLogger.error).toHaveBeenCalledWith( - 'Price source permanently misconfigured', - expect.objectContaining({ source: 'test-source', assetCode: 'XLM', cooldownMs: 60000 }) - ); - }); - - test('open() called again while already open does not repeat the error log', () => { - const { createCircuitBreaker } = loadCircuitBreaker(); - const circuit = createCircuitBreaker({ - sourceName: 'test-source', - cooldownMs: 60000, - reminderIntervalMs: 30000, - }); - - circuit.open(); - circuit.open(); - circuit.open(); - - expect(mockLogger.error).toHaveBeenCalledTimes(1); - }); - - test('remains open until cooldownMs elapses', () => { - const { createCircuitBreaker } = loadCircuitBreaker(); - const circuit = createCircuitBreaker({ - sourceName: 'test-source', - cooldownMs: 60000, - reminderIntervalMs: 30000, - }); - - circuit.open(); - jest.advanceTimersByTime(59999); - expect(circuit.isOpen()).toBe(true); - - jest.advanceTimersByTime(2); - expect(circuit.isOpen()).toBe(false); - }); - - test('close() resets the circuit immediately', () => { - const { createCircuitBreaker } = loadCircuitBreaker(); - const circuit = createCircuitBreaker({ - sourceName: 'test-source', - cooldownMs: 60000, - reminderIntervalMs: 30000, - }); - - circuit.open(); - expect(circuit.isOpen()).toBe(true); - - circuit.close(); - expect(circuit.isOpen()).toBe(false); - expect(circuit.getState()).toEqual({ source: 'test-source', open: false, openUntil: null }); - }); - - test('noteSkipped logs at most once per reminderIntervalMs while open', () => { - const { createCircuitBreaker } = loadCircuitBreaker(); - const circuit = createCircuitBreaker({ - sourceName: 'test-source', - cooldownMs: 60000, - reminderIntervalMs: 30000, - }); - - circuit.open(); - mockLogger.warn.mockClear(); - - // open() already logged the initial failure at error level and stamped - // the reminder clock, so immediate skips shouldn't double-log a warn. - circuit.noteSkipped(); - circuit.noteSkipped(); - circuit.noteSkipped(); - expect(mockLogger.warn).not.toHaveBeenCalled(); - - jest.advanceTimersByTime(30000); - circuit.noteSkipped(); - circuit.noteSkipped(); - expect(mockLogger.warn).toHaveBeenCalledTimes(1); - }); - - test('re-opening after a fresh failure logs the error again', () => { - const { createCircuitBreaker } = loadCircuitBreaker(); - const circuit = createCircuitBreaker({ - sourceName: 'test-source', - cooldownMs: 60000, - reminderIntervalMs: 30000, - }); - - circuit.open(); - jest.advanceTimersByTime(60001); - expect(circuit.isOpen()).toBe(false); - - circuit.open(); - expect(mockLogger.error).toHaveBeenCalledTimes(2); - }); -}); diff --git a/test/coingecko.test.js b/test/coingecko.test.js deleted file mode 100644 index 8fa5219..0000000 --- a/test/coingecko.test.js +++ /dev/null @@ -1,190 +0,0 @@ -'use strict'; - -const mockGet = jest.fn(); -const mockAxiosCreate = jest.fn(() => ({ get: mockGet })); - -const mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -}; - -jest.mock('axios', () => ({ - create: mockAxiosCreate, -})); - -jest.mock('../src/config', () => ({ - coingecko: { - apiKey: 'cg-test-key', - baseUrl: 'https://api.coingecko.test/api/v3', - }, - priceSources: { - circuitCooldownMs: 900000, - circuitReminderIntervalMs: 300000, - }, -})); - -jest.mock('../src/logger', () => mockLogger); - -function priceResponse(coinId, price) { - return { data: { [coinId]: { usd: price } } }; -} - -function loadSource() { - jest.resetModules(); - mockGet.mockReset(); - mockAxiosCreate.mockClear(); - mockAxiosCreate.mockReturnValue({ get: mockGet }); - mockLogger.warn.mockClear(); - mockLogger.debug.mockClear(); - mockLogger.error.mockClear(); - return require('../src/services/sources/coingecko'); -} - -describe('CoinGecko source', () => { - test('returns USD price for a supported asset (XLM)', async () => { - const coingecko = loadSource(); - mockGet.mockResolvedValueOnce(priceResponse('stellar', 0.11)); - - const price = await coingecko.fetchPrice('XLM'); - - expect(price).toBe(0.11); - expect(mockAxiosCreate).toHaveBeenCalledWith({ - baseURL: 'https://api.coingecko.test/api/v3', - headers: { Accept: 'application/json', 'x-cg-demo-api-key': 'cg-test-key' }, - timeout: 10000, - }); - expect(mockGet).toHaveBeenCalledWith('/simple/price', { - params: { ids: 'stellar', vs_currencies: 'usd' }, - }); - }); - - test('returns null for an unsupported asset without calling CoinGecko', async () => { - const coingecko = loadSource(); - - const price = await coingecko.fetchPrice('DOGE'); - - expect(price).toBeNull(); - expect(mockGet).not.toHaveBeenCalled(); - }); - - test('returns null when the response omits usd price', async () => { - const coingecko = loadSource(); - mockGet.mockResolvedValueOnce({ data: { stellar: {} } }); - - await expect(coingecko.fetchPrice('XLM')).resolves.toBeNull(); - }); - - test('throws non-retryable HTTP 401 errors for an invalid API key', async () => { - const coingecko = loadSource(); - const authError = new Error('unauthorized'); - authError.response = { status: 401 }; - mockGet.mockRejectedValueOnce(authError); - - await expect(coingecko.fetchPrice('XLM')).rejects.toThrow('unauthorized'); - expect(authError.nonRetryable).toBe(true); - expect(mockLogger.warn).toHaveBeenCalledWith('CoinGecko authentication failed', { assetCode: 'XLM' }); - }); - - test('returns null and logs on HTTP 429 rate limits, without throwing', async () => { - const coingecko = loadSource(); - const rateLimitError = new Error('too many requests'); - rateLimitError.response = { status: 429 }; - mockGet.mockRejectedValueOnce(rateLimitError); - - const price = await coingecko.fetchPrice('XLM'); - - expect(price).toBeNull(); - expect(mockLogger.warn).toHaveBeenCalledWith('CoinGecko rate limit hit', { assetCode: 'XLM' }); - }); - - test('returns null and logs a generic failure for other errors, without throwing', async () => { - const coingecko = loadSource(); - const networkError = new Error('ECONNRESET'); - mockGet.mockRejectedValueOnce(networkError); - - const price = await coingecko.fetchPrice('XLM'); - - expect(price).toBeNull(); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'CoinGecko price fetch failed', - { assetCode: 'XLM', error: 'ECONNRESET' } - ); - }); - - describe('circuit breaker (#95)', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - test('opens the circuit on a 401 and skips the HTTP request on the next fetch', async () => { - const coingecko = loadSource(); - const authError = new Error('unauthorized'); - authError.response = { status: 401 }; - mockGet.mockRejectedValueOnce(authError); - - await expect(coingecko.fetchPrice('XLM')).rejects.toThrow('unauthorized'); - expect(coingecko.getCircuitState().open).toBe(true); - - mockGet.mockClear(); - const price = await coingecko.fetchPrice('XLM'); - - expect(price).toBeNull(); - expect(mockGet).not.toHaveBeenCalled(); - }); - - test('retries after cooldown and closes the circuit on success', async () => { - const coingecko = loadSource(); - const authError = new Error('unauthorized'); - authError.response = { status: 401 }; - mockGet.mockRejectedValueOnce(authError); - await expect(coingecko.fetchPrice('XLM')).rejects.toThrow('unauthorized'); - - jest.advanceTimersByTime(900001); - mockGet.mockClear(); - mockGet.mockResolvedValueOnce(priceResponse('stellar', 0.12)); - - const price = await coingecko.fetchPrice('XLM'); - - expect(mockGet).toHaveBeenCalledTimes(1); - expect(price).toBe(0.12); - expect(coingecko.getCircuitState()).toEqual({ - source: 'coingecko', - open: false, - openUntil: null, - }); - }); - - test('403s (CDN/firewall block) are unaffected by the circuit breaker', async () => { - const coingecko = loadSource(); - const forbiddenError = new Error('forbidden'); - forbiddenError.response = { status: 403 }; - mockGet.mockRejectedValue(forbiddenError); - - const price = await coingecko.fetchPrice('XLM'); - - expect(price).toBeNull(); - expect(coingecko.getCircuitState().open).toBe(false); - }); - - test('429s are unaffected by the circuit breaker', async () => { - const coingecko = loadSource(); - const rateLimitError = new Error('too many requests'); - rateLimitError.response = { status: 429 }; - mockGet.mockRejectedValue(rateLimitError); - - await coingecko.fetchPrice('XLM'); - const price = await coingecko.fetchPrice('XLM'); - - expect(price).toBeNull(); - expect(mockGet).toHaveBeenCalledTimes(2); - expect(coingecko.getCircuitState().open).toBe(false); - }); - }); -}); diff --git a/test/coinmarketcap.test.js b/test/coinmarketcap.test.js deleted file mode 100644 index b97dc85..0000000 --- a/test/coinmarketcap.test.js +++ /dev/null @@ -1,282 +0,0 @@ -'use strict'; - -const mockGet = jest.fn(); -const mockAxiosCreate = jest.fn(() => ({ get: mockGet })); - -const mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -}; - -const mockUsdcIssuer = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA'; - -jest.mock('axios', () => ({ - create: mockAxiosCreate, -})); - -jest.mock('../src/config', () => ({ - stellar: { - usdcIssuer: mockUsdcIssuer, - }, - coinmarketcap: { - apiKey: 'cmc-test-key', - baseUrl: 'https://pro-api.coinmarketcap.test/v1', - assetIssuerMap: { - XLM: { symbol: 'XLM' }, - [`USDC:${mockUsdcIssuer}`]: { id: 3408 }, - }, - }, - priceSources: { - circuitCooldownMs: 900000, - circuitReminderIntervalMs: 300000, - }, -})); - -jest.mock('../src/logger', () => mockLogger); - -function quoteResponse(symbol, price) { - return { - data: { - data: { - [symbol]: { - quote: { - USD: { price }, - }, - }, - }, - }, - }; -} - -function loadSource() { - jest.resetModules(); - mockGet.mockReset(); - mockAxiosCreate.mockClear(); - mockAxiosCreate.mockReturnValue({ get: mockGet }); - mockLogger.warn.mockClear(); - mockLogger.debug.mockClear(); - mockLogger.error.mockClear(); - return require('../src/services/sources/coinmarketcap'); -} - -describe('CoinMarketCap source', () => { - test('returns USD price on supported XLM response', async () => { - const coinmarketcap = loadSource(); - mockGet.mockResolvedValueOnce(quoteResponse('XLM', 0.1234)); - - const price = await coinmarketcap.fetchPrice('XLM'); - - expect(price).toBe(0.1234); - expect(mockAxiosCreate).toHaveBeenCalledWith({ - baseURL: 'https://pro-api.coinmarketcap.test/v1', - headers: { - Accept: 'application/json', - 'X-CMC_PRO_API_KEY': 'cmc-test-key', - }, - timeout: 10000, - }); - expect(mockGet).toHaveBeenCalledWith('/cryptocurrency/quotes/latest', { - params: { - symbol: 'XLM', - convert: 'USD', - }, - }); - }); - - test('returns null for unsupported asset symbols without calling CMC', async () => { - const coinmarketcap = loadSource(); - - const price = await coinmarketcap.fetchPrice('DOGE'); - - expect(price).toBeNull(); - expect(mockGet).not.toHaveBeenCalled(); - expect(mockLogger.debug).toHaveBeenCalledWith( - 'Asset not supported by CoinMarketCap', - expect.objectContaining({ assetCode: 'DOGE' }) - ); - }); - - test('returns USDC price only for the configured Stellar issuer', async () => { - const coinmarketcap = loadSource(); - mockGet.mockResolvedValueOnce(quoteResponse('3408', 1.0003)); - - const price = await coinmarketcap.fetchPrice('USDC', mockUsdcIssuer); - - expect(price).toBe(1.0003); - expect(mockGet).toHaveBeenCalledWith('/cryptocurrency/quotes/latest', { - params: { - id: 3408, - convert: 'USD', - }, - }); - }); - - test('returns null for USDC with an unknown Stellar issuer', async () => { - const coinmarketcap = loadSource(); - const wrongIssuer = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; - - const price = await coinmarketcap.fetchPrice('USDC', wrongIssuer); - - expect(price).toBeNull(); - expect(mockGet).not.toHaveBeenCalled(); - expect(mockLogger.debug).toHaveBeenCalledWith( - 'Issuer not supported by CoinMarketCap', - { assetCode: 'USDC', issuer: wrongIssuer } - ); - }); - - test('throws non-retryable HTTP 401 errors for invalid API keys', async () => { - const coinmarketcap = loadSource(); - const authError = new Error('unauthorized'); - authError.response = { status: 401 }; - mockGet.mockRejectedValueOnce(authError); - - await expect(coinmarketcap.fetchPrice('XLM')).rejects.toThrow('unauthorized'); - expect(authError.nonRetryable).toBe(true); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'CoinMarketCap authentication failed', - { assetCode: 'XLM' } - ); - }); - - test('returns null and logs retry_after on HTTP 429 rate limits', async () => { - const coinmarketcap = loadSource(); - const rateLimitError = new Error('too many requests'); - rateLimitError.response = { - status: 429, - headers: { 'retry-after': '60' }, - }; - mockGet.mockRejectedValueOnce(rateLimitError); - - const price = await coinmarketcap.fetchPrice('XLM'); - - expect(price).toBeNull(); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'CoinMarketCap rate limit hit', - { assetCode: 'XLM', retry_after: '60' } - ); - }); - - test('returns null when CMC omits quote data for a mapped symbol', async () => { - const coinmarketcap = loadSource(); - mockGet.mockResolvedValueOnce({ data: { data: { XLM: {} } } }); - - await expect(coinmarketcap.fetchPrice('XLM')).resolves.toBeNull(); - }); - - describe('circuit breaker (#95)', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - test('opens the circuit on a 401 and skips the HTTP request on the next fetch', async () => { - const coinmarketcap = loadSource(); - const authError = new Error('unauthorized'); - authError.response = { status: 401 }; - mockGet.mockRejectedValueOnce(authError); - - await expect(coinmarketcap.fetchPrice('XLM')).rejects.toThrow('unauthorized'); - expect(coinmarketcap.getCircuitState()).toEqual({ - source: 'coinmarketcap', - open: true, - openUntil: new Date('2026-01-01T00:15:00.000Z').toISOString(), - }); - - mockGet.mockClear(); - const price = await coinmarketcap.fetchPrice('XLM'); - - expect(price).toBeNull(); - expect(mockGet).not.toHaveBeenCalled(); - }); - - test('retries the source after the cooldown window elapses', async () => { - const coinmarketcap = loadSource(); - const authError = new Error('unauthorized'); - authError.response = { status: 401 }; - mockGet.mockRejectedValueOnce(authError); - await expect(coinmarketcap.fetchPrice('XLM')).rejects.toThrow('unauthorized'); - - jest.advanceTimersByTime(900001); - mockGet.mockClear(); - mockGet.mockResolvedValueOnce(quoteResponse('XLM', 0.15)); - - const price = await coinmarketcap.fetchPrice('XLM'); - - expect(mockGet).toHaveBeenCalledTimes(1); - expect(price).toBe(0.15); - }); - - test('a successful retry closes the circuit', async () => { - const coinmarketcap = loadSource(); - const authError = new Error('unauthorized'); - authError.response = { status: 401 }; - mockGet.mockRejectedValueOnce(authError); - await expect(coinmarketcap.fetchPrice('XLM')).rejects.toThrow('unauthorized'); - - jest.advanceTimersByTime(900001); - mockGet.mockResolvedValueOnce(quoteResponse('XLM', 0.15)); - await coinmarketcap.fetchPrice('XLM'); - - expect(coinmarketcap.getCircuitState()).toEqual({ - source: 'coinmarketcap', - open: false, - openUntil: null, - }); - }); - - test('a fresh 401 after cooldown re-opens the circuit with a new window', async () => { - const coinmarketcap = loadSource(); - const authError = new Error('unauthorized'); - authError.response = { status: 401 }; - mockGet.mockRejectedValueOnce(authError); - await expect(coinmarketcap.fetchPrice('XLM')).rejects.toThrow('unauthorized'); - - jest.advanceTimersByTime(900001); - mockGet.mockRejectedValueOnce(authError); - await expect(coinmarketcap.fetchPrice('XLM')).rejects.toThrow('unauthorized'); - - expect(coinmarketcap.getCircuitState().open).toBe(true); - expect(mockLogger.error).toHaveBeenCalledTimes(2); - }); - - test('the first 401 logs distinctly at error level; repeated skips while open do not', async () => { - const coinmarketcap = loadSource(); - const authError = new Error('unauthorized'); - authError.response = { status: 401 }; - mockGet.mockRejectedValueOnce(authError); - await expect(coinmarketcap.fetchPrice('XLM')).rejects.toThrow('unauthorized'); - - expect(mockLogger.error).toHaveBeenCalledTimes(1); - expect(mockLogger.error).toHaveBeenCalledWith( - 'Price source permanently misconfigured', - expect.objectContaining({ source: 'coinmarketcap' }) - ); - - await coinmarketcap.fetchPrice('XLM'); - await coinmarketcap.fetchPrice('XLM'); - - expect(mockLogger.error).toHaveBeenCalledTimes(1); - }); - - test('429s are unaffected by the circuit breaker', async () => { - const coinmarketcap = loadSource(); - const rateLimitError = new Error('too many requests'); - rateLimitError.response = { status: 429 }; - mockGet.mockRejectedValue(rateLimitError); - - await coinmarketcap.fetchPrice('XLM'); - const price = await coinmarketcap.fetchPrice('XLM'); - - expect(price).toBeNull(); - expect(mockGet).toHaveBeenCalledTimes(2); - expect(coinmarketcap.getCircuitState().open).toBe(false); - }); - }); -}); diff --git a/test/config.test.js b/test/config.test.js deleted file mode 100644 index 909569e..0000000 --- a/test/config.test.js +++ /dev/null @@ -1,126 +0,0 @@ -'use strict'; - -const path = require('path'); -const { spawnSync } = require('child_process'); - -const repoRoot = path.join(__dirname, '..'); - -function cleanProcessEnv(overrides) { - return { - PATH: process.env.PATH, - Path: process.env.Path, - SystemRoot: process.env.SystemRoot, - COMSPEC: process.env.COMSPEC, - TEMP: process.env.TEMP, - TMP: process.env.TMP, - ...overrides, - }; -} - -function runConfig(script, env) { - return spawnSync(process.execPath, ['-e', script], { - cwd: repoRoot, - env: cleanProcessEnv(env), - encoding: 'utf8', - }); -} - -describe('configuration validation', () => { - test('exits before startup and reports every invalid production variable', () => { - const result = runConfig("require('./src/config')", { - NODE_ENV: 'production', - REDIS_URL: 'not-a-url', - LOG_LEVEL: 'verbose', - PRICE_CACHE_TTL_SECONDS: 'soon', - }); - - const output = `${result.stdout}\n${result.stderr}`; - - expect(result.status).toBe(1); - expect(output).toContain('DATABASE_URL'); - expect(output).toContain('REDIS_URL'); - expect(output).toContain('LOG_LEVEL'); - expect(output).toContain('PRICE_CACHE_TTL_SECONDS'); - }); - - test('loads safe in-process defaults under NODE_ENV=test', () => { - const result = runConfig( - [ - "const config = require('./src/config');", - 'console.log(JSON.stringify({', - ' port: config.port,', - ' databaseUrl: config.databaseUrl,', - ' redisUrl: config.redis.url,', - ' price: config.price,', - ' watchedAssets: config.watchedAssets,', - ' airdrops: config.airdrops,', - '}));', - ].join(' '), - { NODE_ENV: 'test' } - ); - - expect(result.status).toBe(0); - - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ - port: 3000, - databaseUrl: 'postgres://localhost/smartdrop_test', - redisUrl: 'redis://localhost:6379', - price: { - cacheTtl: 60, - refreshInterval: 30, - staleThresholdMinutes: 5, - anomalyThresholdPercent: 20, - circuitBreaker: { - failureThreshold: 3, - successThreshold: 1, - timeoutMs: 30000, - }, - }, - watchedAssets: [], - airdrops: { - expiryCheckIntervalSeconds: 60, - ledgerCacheTtlMs: 5000, - expiryScanBatchSize: 100, - csvMaxBytes: 5 * 1024 * 1024, - jsonMaxBytes: 2 * 1024 * 1024, - maxRecipients: 10000, - rateLimit: { - windowSeconds: 60, - max: 10, - }, - }, - }); - }); - - test('parses watched assets from WATCHED_ASSETS', () => { - const issuer = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; - const result = runConfig( - [ - "const config = require('./src/config');", - 'console.log(JSON.stringify(config.watchedAssets));', - ].join(' '), - { - NODE_ENV: 'test', - WATCHED_ASSETS: `XLM,USDC:${issuer},XLM`, - } - ); - - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout.trim())).toEqual([ - { code: 'XLM', issuer: null }, - { code: 'USDC', issuer }, - ]); - }); - - test('rejects malformed watched assets during config loading', () => { - const result = runConfig("require('./src/config')", { - NODE_ENV: 'test', - WATCHED_ASSETS: 'usdc:not-a-stellar-address', - }); - - const output = `${result.stdout}\n${result.stderr}`; - expect(result.status).toBe(1); - expect(output).toContain('WATCHED_ASSETS'); - }); -}); diff --git a/test/cors.test.js b/test/cors.test.js deleted file mode 100644 index eda61ab..0000000 --- a/test/cors.test.js +++ /dev/null @@ -1,124 +0,0 @@ -'use strict'; - -const express = require('express'); -const request = require('supertest'); -const buildCorsMiddleware = require('../src/middleware/cors'); - -const ALLOWED = ['http://localhost:3000', 'https://app.smartdrop.io']; - -function buildApp(allowedOrigins) { - const app = express(); - app.use(buildCorsMiddleware(allowedOrigins)); - app.get('/test', (req, res) => res.json({ ok: true })); - app.use((err, req, res, _next) => { - res.status(err.status || 500).json({ error: err.message }); - }); - return app; -} - -describe('CORS allowed origins', () => { - let app; - beforeAll(() => { app = buildApp(ALLOWED); }); - - test('allowed origin receives Access-Control-Allow-Origin header', async () => { - const res = await request(app) - .get('/test') - .set('Origin', 'http://localhost:3000'); - expect(res.status).toBe(200); - expect(res.headers['access-control-allow-origin']).toBe('http://localhost:3000'); - }); - - test('second allowed origin also receives CORS header', async () => { - const res = await request(app) - .get('/test') - .set('Origin', 'https://app.smartdrop.io'); - expect(res.status).toBe(200); - expect(res.headers['access-control-allow-origin']).toBe('https://app.smartdrop.io'); - }); - - test('credentials header is set for allowed origin', async () => { - const res = await request(app) - .get('/test') - .set('Origin', 'http://localhost:3000'); - expect(res.headers['access-control-allow-credentials']).toBe('true'); - }); - - test('preflight OPTIONS returns 204 with allowed methods', async () => { - const res = await request(app) - .options('/test') - .set('Origin', 'http://localhost:3000') - .set('Access-Control-Request-Method', 'POST'); - expect(res.status).toBe(204); - expect(res.headers['access-control-allow-methods']).toMatch(/POST/); - }); - - test('preflight respects maxAge cache directive', async () => { - const res = await request(app) - .options('/test') - .set('Origin', 'http://localhost:3000') - .set('Access-Control-Request-Method', 'GET'); - expect(res.headers['access-control-max-age']).toBe('86400'); - }); -}); - -describe('CORS rejected origins', () => { - let app; - beforeAll(() => { app = buildApp(ALLOWED); }); - - test('unknown origin receives 403', async () => { - const res = await request(app) - .get('/test') - .set('Origin', 'https://evil.com'); - expect(res.status).toBe(403); - }); - - test('rejected origin does not receive Access-Control-Allow-Origin', async () => { - const res = await request(app) - .get('/test') - .set('Origin', 'https://evil.com'); - expect(res.headers['access-control-allow-origin']).toBeUndefined(); - }); - - test('subdomain of allowed origin is not automatically permitted', async () => { - const res = await request(app) - .get('/test') - .set('Origin', 'https://sub.app.smartdrop.io'); - expect(res.status).toBe(403); - }); -}); - -describe('CORS no-origin requests (server-to-server, curl)', () => { - let app; - beforeAll(() => { app = buildApp(ALLOWED); }); - - test('request without Origin header is allowed through', async () => { - const res = await request(app).get('/test'); - expect(res.status).toBe(200); - expect(res.body).toEqual({ ok: true }); - }); -}); - -describe('CORS_ALLOWED_ORIGINS config parsing', () => { - test('dev default allows localhost:3000 and localhost:3001', () => { - const original = process.env.CORS_ALLOWED_ORIGINS; - delete process.env.CORS_ALLOWED_ORIGINS; - jest.resetModules(); - const config = require('../src/config'); - expect(config.corsAllowedOrigins).toContain('http://localhost:3000'); - expect(config.corsAllowedOrigins).toContain('http://localhost:3001'); - process.env.CORS_ALLOWED_ORIGINS = original; - }); - - test('parses comma-separated origins and trims whitespace', () => { - const original = process.env.CORS_ALLOWED_ORIGINS; - process.env.CORS_ALLOWED_ORIGINS = ' https://app.smartdrop.io , https://staging.smartdrop.io '; - jest.resetModules(); - const config = require('../src/config'); - expect(config.corsAllowedOrigins).toEqual([ - 'https://app.smartdrop.io', - 'https://staging.smartdrop.io', - ]); - if (original !== undefined) process.env.CORS_ALLOWED_ORIGINS = original; - else delete process.env.CORS_ALLOWED_ORIGINS; - }); -}); diff --git a/test/deliveryRepository.test.js b/test/deliveryRepository.test.js deleted file mode 100644 index 55315fb..0000000 --- a/test/deliveryRepository.test.js +++ /dev/null @@ -1,116 +0,0 @@ -'use strict'; - -const { createCacheMock } = require('./helpers/cacheMock'); - -const mockHelper = createCacheMock(); -const { reset, redis, zsets } = mockHelper; - -jest.mock('../src/services/cache', () => mockHelper.cacheMock); -jest.mock('../src/logger', () => ({ - info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), -})); - -const deliveryRepo = require('../src/repositories/deliveryRepository'); - -const RETRY_QUEUE_KEY = 'webhooks:retries'; - -beforeEach(() => reset()); - -function seedDueRetries(count, { dueAt = 1000 } = {}) { - const ids = []; - for (let i = 0; i < count; i += 1) { - const id = `dlv_${String(i).padStart(4, '0')}`; - ids.push(id); - redis.zadd(RETRY_QUEUE_KEY, dueAt + i, id); - } - return ids; -} - -describe('popDueRetries', () => { - test('returns and removes due ids up to max', async () => { - seedDueRetries(5); - const popped = await deliveryRepo.popDueRetries(2000, 3); - expect(popped).toHaveLength(3); - const remaining = zsets.get(RETRY_QUEUE_KEY); - expect(remaining.size).toBe(2); - }); - - test('ignores retries not yet due', async () => { - await deliveryRepo.scheduleRetry('dlv_future', 5000); - const popped = await deliveryRepo.popDueRetries(1000, 25); - expect(popped).toEqual([]); - expect(zsets.get(RETRY_QUEUE_KEY).size).toBe(1); - }); - - test('empty due set returns [] without error', async () => { - expect(await deliveryRepo.popDueRetries(Date.now(), 25)).toEqual([]); - }); - - test('two concurrent callers never receive overlapping ids', async () => { - const seeded = seedDueRetries(50); - const [first, second] = await Promise.all([ - deliveryRepo.popDueRetries(2000, 25), - deliveryRepo.popDueRetries(2000, 25), - ]); - - const overlap = first.filter((id) => second.includes(id)); - expect(overlap).toEqual([]); - - const union = new Set([...first, ...second]); - expect(union.size).toBe(50); - expect([...union].sort()).toEqual([...seeded].sort()); - expect(zsets.get(RETRY_QUEUE_KEY).size).toBe(0); - }); - - test('many concurrent callers still partition the queue with no duplicates', async () => { - const seeded = seedDueRetries(100); - const results = await Promise.all( - Array.from({ length: 4 }, () => deliveryRepo.popDueRetries(2000, 25)), - ); - - const allIds = results.flat(); - expect(allIds).toHaveLength(100); - expect(new Set(allIds).size).toBe(100); - expect([...allIds].sort()).toEqual([...seeded].sort()); - }); - - test('regression: the old read-then-delete pattern double-claims under a race', async () => { - // Demonstrates the bug this fix closes: two round trips to Redis (a - // ZRANGEBYSCORE followed later by a ZREM) let a second caller read the - // same ids before the first caller's ZREM has run. The production code - // no longer does this - popDueRetries now uses a single atomic Lua - // round trip - but this test proves the failure mode it replaces. - seedDueRetries(10); - - async function racyPop(nowMs, max) { - const ids = await redis.zrangebyscore(RETRY_QUEUE_KEY, '-inf', nowMs, 'LIMIT', 0, max); - await new Promise((resolve) => setTimeout(resolve, 10)); - if (ids.length > 0) await redis.zrem(RETRY_QUEUE_KEY, ...ids); - return ids; - } - - const [first, second] = await Promise.all([racyPop(2000, 10), racyPop(2000, 10)]); - const overlap = first.filter((id) => second.includes(id)); - expect(overlap.length).toBeGreaterThan(0); - }); -}); - -describe('cancelRetry / scheduleRetry / listByWebhook (unchanged by the atomic fix)', () => { - test('scheduleRetry adds a member with the given score', async () => { - await deliveryRepo.scheduleRetry('dlv_a', 12345); - expect(zsets.get(RETRY_QUEUE_KEY).get('dlv_a')).toBe(12345); - }); - - test('cancelRetry removes a scheduled retry', async () => { - await deliveryRepo.scheduleRetry('dlv_b', 12345); - await deliveryRepo.cancelRetry('dlv_b'); - expect(zsets.get(RETRY_QUEUE_KEY).has('dlv_b')).toBe(false); - }); - - test('listByWebhook returns all persisted deliveries for that webhook', async () => { - const a = await deliveryRepo.create({ webhook_id: 'wh_1', event_id: 'evt_a', event_type: 'x' }); - const b = await deliveryRepo.create({ webhook_id: 'wh_1', event_id: 'evt_b', event_type: 'x' }); - const list = await deliveryRepo.listByWebhook('wh_1', 10); - expect(list.map((d) => d.id).sort()).toEqual([a.id, b.id].sort()); - }); -}); diff --git a/test/errorHandler.test.js b/test/errorHandler.test.js deleted file mode 100644 index 11f002b..0000000 --- a/test/errorHandler.test.js +++ /dev/null @@ -1,122 +0,0 @@ -const request = require('supertest'); -const express = require('express'); -const AppError = require('../src/errors/AppError'); -const { requestIdMiddleware } = require('../src/middleware/requestId'); -const { errorHandler, notFoundHandler } = require('../src/middleware/errorHandler'); -const buildRateLimit = require('../src/middleware/rateLimit'); -const cache = require('../src/services/cache'); - -jest.mock('../src/logger', () => ({ - info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), -})); - -jest.mock('../src/services/cache', () => ({ - getClient: jest.fn(), -})); - -function buildApp(route) { - const app = express(); - app.use(express.json()); - app.use(requestIdMiddleware); - route(app); - app.use(notFoundHandler); - app.use(errorHandler); - return app; -} - -describe('structured error responses', () => { - test.each([ - ['VALIDATION_ERROR', 400], - ['UNAUTHORIZED', 401], - ['NOT_FOUND', 404], - ['PAYLOAD_TOO_LARGE', 413], - ['UPSTREAM_ERROR', 502], - ['INTERNAL_ERROR', 500], - ])('returns standard shape for %s', async (code, status) => { - const app = buildApp((app) => { - app.get('/boom', (_req, _res, next) => next(new AppError(code, `${code} message`, status, { field: 'x' }))); - }); - - const res = await request(app).get('/boom'); - - expect(res.status).toBe(status); - expect(res.body).toEqual({ - error: { - code, - message: `${code} message`, - details: { field: 'x' }, - request_id: expect.stringMatching(/^req_/), - } - }); - expect(res.headers['x-request-id']).toBe(res.body.error.request_id); - }); - - test('omits stack traces for unhandled errors', async () => { - const app = buildApp((app) => { - app.get('/boom', () => { throw new Error('secret stack details'); }); - }); - - const res = await request(app).get('/boom'); - - expect(res.status).toBe(500); - expect(res.body.error).toEqual({ - code: 'INTERNAL_ERROR', - message: 'An unexpected error occurred', - request_id: expect.stringMatching(/^req_/) - }); - expect(JSON.stringify(res.body)).not.toContain('secret stack details'); - expect(JSON.stringify(res.body)).not.toContain('stack'); - }); - - test('returns a structured 413 when the JSON body limit is exceeded', async () => { - const app = express(); - app.use(requestIdMiddleware); - app.use(express.json({ limit: 10 })); - app.post('/payload', (_req, res) => res.json({ ok: true })); - app.use(errorHandler); - - const res = await request(app).post('/payload').send({ value: 'too large' }); - - expect(res.status).toBe(413); - expect(res.body.error).toEqual({ - code: 'PAYLOAD_TOO_LARGE', - message: 'Request body is too large', - request_id: expect.stringMatching(/^req_/), - }); - }); - - test('adds request_id to success responses', async () => { - const app = buildApp((app) => { - app.get('/ok', (_req, res) => res.json({ ok: true })); - }); - - const res = await request(app).get('/ok'); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ ok: true, request_id: expect.stringMatching(/^req_/) }); - }); - - test('returns structured 404 for undefined routes', async () => { - const app = buildApp(() => {}); - const res = await request(app).get('/missing'); - expect(res.status).toBe(404); - expect(res.body.error.code).toBe('NOT_FOUND'); - expect(res.body.error.request_id).toMatch(/^req_/); - }); - - test('returns RATE_LIMITED shape', async () => { - cache.getClient.mockReturnValue({ incr: jest.fn().mockResolvedValue(2), expire: jest.fn().mockResolvedValue(1) }); - const app = buildApp((app) => { - app.get('/limited', buildRateLimit({ windowSeconds: 60, max: 1, keyPrefix: 'test' }), (_req, res) => res.json({ ok: true })); - }); - - const res = await request(app).get('/limited'); - expect(res.status).toBe(429); - expect(res.body.error.code).toBe('RATE_LIMITED'); - expect(res.body.error.details).toEqual({ - limit: 1, - window_seconds: 60, - retry_after_seconds: expect.any(Number), - }); - }); -}); diff --git a/test/eventPoller.test.js b/test/eventPoller.test.js deleted file mode 100644 index 1bae0bb..0000000 --- a/test/eventPoller.test.js +++ /dev/null @@ -1,237 +0,0 @@ -'use strict'; - -const { nativeToScVal } = require('stellar-sdk'); -const { EventPoller } = require('../src/indexer/eventPoller'); - -function contractEvent(overrides = {}) { - return { - id: 'evt-1', - type: 'contract', - ledger: 20, - ledgerClosedAt: '2026-06-25T00:00:00Z', - pagingToken: '20-1', - inSuccessfulContractCall: true, - topic: [nativeToScVal('airdrop_created', { type: 'symbol' })], - value: nativeToScVal({ - airdrop_id: 'drop-1', - creator: 'GCREATOR', - token: 'USDC', - total_amount: 1000n, - expiry_ledger: 500n, - }), - ...overrides, - }; -} - -// N distinct events at consecutive ledgers starting at `startLedger`, used -// to simulate a burst/backlog large enough to fill a batch of size `n`. -function contractEvents(n, startLedger) { - return Array.from({ length: n }, (_, i) => { - const ledger = startLedger + i; - return contractEvent({ - id: `evt-${ledger}`, - ledger, - pagingToken: `${ledger}-1`, - value: nativeToScVal({ - airdrop_id: `drop-${ledger}`, - creator: 'GCREATOR', - token: 'USDC', - total_amount: 1000n, - expiry_ledger: 500n, - }), - }); - }); -} - -describe('EventPoller', () => { - test('polls Soroban RPC, stores parsed events, and advances last ledger', async () => { - const server = { - getEvents: jest.fn(async () => ({ - latestLedger: 25, - events: [contractEvent()], - })), - }; - const store = { - getLastLedger: jest.fn(async () => null), - saveEvent: jest.fn(async () => {}), - setLastLedger: jest.fn(async () => {}), - }; - const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }; - - const poller = new EventPoller({ - enabled: true, - contractId: 'CCONTRACT', - startLedger: 10, - pollLimit: 5, - server, - store, - logger, - }); - - const result = await poller.pollOnce(); - - expect(server.getEvents).toHaveBeenCalledWith({ - startLedger: 10, - filters: [{ type: 'contract', contractIds: ['CCONTRACT'] }], - limit: 5, - }); - expect(store.saveEvent).toHaveBeenCalledWith(expect.objectContaining({ - event_name: 'airdrop_created', - data: expect.objectContaining({ airdrop_id: 'drop-1', total_amount: '1000' }), - })); - expect(store.setLastLedger).toHaveBeenCalledWith(25); - expect(result).toMatchObject({ indexed_events: 1, latest_ledger: 25 }); - expect(poller.getStatus()).toMatchObject({ latest_ledger: 25, last_error: null }); - }); - - test('continues from the ledger after the saved checkpoint', async () => { - const server = { - getEvents: jest.fn(async () => ({ latestLedger: 25, events: [] })), - }; - const store = { - getLastLedger: jest.fn(async () => 19), - saveEvent: jest.fn(async () => {}), - setLastLedger: jest.fn(async () => {}), - }; - - const poller = new EventPoller({ - enabled: true, - contractId: 'CCONTRACT', - startLedger: 10, - server, - store, - }); - - await poller.pollOnce(); - - expect(server.getEvents.mock.calls[0][0].startLedger).toBe(20); - }); - - test('skips polling when no contract id is configured', async () => { - const poller = new EventPoller({ - enabled: true, - contractId: '', - server: { getEvents: jest.fn() }, - }); - - await expect(poller.pollOnce()).resolves.toMatchObject({ skipped: true }); - }); - - describe('truncated batch (#115)', () => { - test('advances last_ledger only to the last processed event, not to the chain tip', async () => { - const pollLimit = 5; - // Simulates a real burst/backlog: exactly pollLimit events returned, - // last event's ledger (24) is far behind the chain tip (500). - const events = contractEvents(pollLimit, 20); - const server = { - getEvents: jest.fn(async () => ({ latestLedger: 500, events })), - }; - const store = { - getLastLedger: jest.fn(async () => null), - saveEvent: jest.fn(async () => {}), - setLastLedger: jest.fn(async () => {}), - }; - const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }; - - const poller = new EventPoller({ - enabled: true, - contractId: 'CCONTRACT', - startLedger: 10, - pollLimit, - server, - store, - logger, - }); - - const result = await poller.pollOnce(); - - // Not 500 (response.latestLedger) — that would permanently skip - // whatever exists between ledger 24 and the tip. - expect(store.setLastLedger).toHaveBeenCalledWith(24); - expect(result).toMatchObject({ truncated: true, indexed_events: pollLimit }); - expect(logger.warn).toHaveBeenCalledWith( - 'SmartDrop event poll truncated by pollLimit; more events pending next cycle', - expect.objectContaining({ pollLimit, indexed_events: pollLimit, resumed_from_ledger: 25 }), - ); - }); - - test('the next poll resumes from the last processed event, picking up previously-skippable events', async () => { - const pollLimit = 5; - const firstBatch = contractEvents(pollLimit, 20); // ledgers 20-24 - // Events that would have been silently skipped pre-fix: they sit - // between the last processed ledger (24) and the previous poll's - // chain-tip snapshot (500). - const skippableRangeEvents = contractEvents(2, 100); // ledgers 100-101 - - const server = { getEvents: jest.fn() }; - server.getEvents - .mockImplementationOnce(async () => ({ latestLedger: 500, events: firstBatch })) - .mockImplementationOnce(async () => ({ latestLedger: 500, events: skippableRangeEvents })); - - // Stateful store, so the second pollOnce() actually reads back what - // the first one wrote — proves resumption across ticks, not just - // within one call. - let lastLedger = null; - const store = { - getLastLedger: jest.fn(async () => lastLedger), - saveEvent: jest.fn(async () => {}), - setLastLedger: jest.fn(async (ledger) => { - lastLedger = ledger; - }), - }; - - const poller = new EventPoller({ - enabled: true, - contractId: 'CCONTRACT', - startLedger: 10, - pollLimit, - server, - store, - logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, - }); - - const first = await poller.pollOnce(); - expect(first.truncated).toBe(true); - expect(lastLedger).toBe(24); - - const second = await poller.pollOnce(); - - // Resumed from 25 (last processed + 1), not 501 (tip + 1) — the - // pre-fix bug would have started here at 501, skipping ledgers - // 25-499 (including skippableRangeEvents) forever. - expect(server.getEvents.mock.calls[1][0].startLedger).toBe(25); - expect(second.indexed_events).toBe(2); - expect(store.saveEvent).toHaveBeenCalledTimes(pollLimit + 2); - }); - - test('a batch smaller than pollLimit still advances to the chain tip and logs no warning', async () => { - const pollLimit = 100; - const events = contractEvents(3, 20); - const server = { - getEvents: jest.fn(async () => ({ latestLedger: 500, events })), - }; - const store = { - getLastLedger: jest.fn(async () => null), - saveEvent: jest.fn(async () => {}), - setLastLedger: jest.fn(async () => {}), - }; - const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }; - - const poller = new EventPoller({ - enabled: true, - contractId: 'CCONTRACT', - startLedger: 10, - pollLimit, - server, - store, - logger, - }); - - const result = await poller.pollOnce(); - - expect(store.setLastLedger).toHaveBeenCalledWith(500); - expect(result.truncated).toBe(false); - expect(logger.warn).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/test/health.test.js b/test/health.test.js deleted file mode 100644 index 9925e26..0000000 --- a/test/health.test.js +++ /dev/null @@ -1,307 +0,0 @@ -'use strict'; - -const request = require('supertest'); - -jest.mock('../src/services/cache', () => ({ - isConnected: jest.fn(() => false), - disconnect: jest.fn(), -})); - -jest.mock('../src/services/priceOracle', () => ({ - getCircuitStates: jest.fn(() => ({ - coingecko: 'closed', - coinmarketcap: 'open', - stellar_dex: 'half-open', - })), - getSourceCircuitStates: jest.fn(() => [ - { source: 'coingecko', open: false, openUntil: null }, - { source: 'coinmarketcap', open: false, openUntil: null }, - ]), - refreshAllCachedPrices: jest.fn(), -})); - -jest.mock('../src/jobs/priceRefresh', () => ({ - start: jest.fn(), - stop: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), -})); - -jest.mock('../src/jobs/webhookRetryWorker', () => ({ - start: jest.fn(), - stop: jest.fn(), - tick: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), -})); - -jest.mock('../src/ws/priceWebSocket', () => ({ - attach: jest.fn(), -})); - -// --------------------------------------------------------------------------- -// Helpers – reset modules between tests so mocks are applied cleanly -// --------------------------------------------------------------------------- - -function loadApp() { - return require('../src/index').app; -} - -// --------------------------------------------------------------------------- -// GET /health – price_source_circuits (pre-existing behaviour) -// --------------------------------------------------------------------------- - -describe('GET /health – price_source_circuits', () => { - test('includes an entry per source that has a circuit breaker', async () => { - jest.resetModules(); - const app = loadApp(); - - const res = await request(app).get('/health'); - - expect(res.status).toBe(200); - expect(Array.isArray(res.body.price_source_circuits)).toBe(true); - - const sourceNames = res.body.price_source_circuits.map((c) => c.source); - expect(sourceNames).toEqual(expect.arrayContaining(['coingecko', 'coinmarketcap'])); - expect(sourceNames).not.toContain('stellar_dex'); - }); - - test('every circuit starts closed with a null openUntil', async () => { - jest.resetModules(); - const app = loadApp(); - - const res = await request(app).get('/health'); - - for (const circuit of res.body.price_source_circuits) { - expect(circuit.open).toBe(false); - expect(circuit.openUntil).toBeNull(); - } - }); -}); - -// --------------------------------------------------------------------------- -// GET /health – overall response shape -// --------------------------------------------------------------------------- - -describe('GET /health – response shape', () => { - test('returns expected top-level fields', async () => { - jest.resetModules(); - const app = loadApp(); - - const res = await request(app).get('/health'); - - expect(res.status).toBe(200); - expect(res.body.circuits).toEqual({ - coingecko: 'closed', - coinmarketcap: 'open', - stellar_dex: 'half-open', - }); - expect(res.body).toHaveProperty('status'); - expect(res.body).toHaveProperty('timestamp'); - expect(res.body).toHaveProperty('redis'); - expect(res.body).toHaveProperty('jobs'); - expect(res.body).toHaveProperty('database'); - expect(res.body).toHaveProperty('price_source_circuits'); - }); - - test('database field reflects configured-but-unused state', async () => { - jest.resetModules(); - const app = loadApp(); - - const res = await request(app).get('/health'); - - expect(res.body.database).toEqual({ - configured: true, - checked: false, - status: 'unused', - }); - }); - - test('jobs field contains price_refresh and webhook_retry_worker entries', async () => { - jest.resetModules(); - const app = loadApp(); - - const res = await request(app).get('/health'); - - expect(res.body.jobs).toHaveProperty('price_refresh'); - expect(res.body.jobs).toHaveProperty('webhook_retry_worker'); - - for (const key of ['price_refresh', 'webhook_retry_worker']) { - const job = res.body.jobs[key]; - expect(job).toHaveProperty('healthy'); - expect(job).toHaveProperty('last_success_at'); - expect(job).toHaveProperty('last_error'); - expect(job).toHaveProperty('stalled'); - } - }); -}); - -// --------------------------------------------------------------------------- -// GET /health – status computation -// --------------------------------------------------------------------------- - -describe('GET /health – status computation', () => { - test('status is ok when Redis is connected and jobs are healthy', async () => { - jest.resetModules(); - - jest.mock('../src/services/cache', () => ({ - isConnected: () => true, - disconnect: jest.fn(), - })); - jest.mock('../src/jobs/priceRefresh', () => ({ - start: jest.fn(), - stop: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), - })); - jest.mock('../src/jobs/webhookRetryWorker', () => ({ - start: jest.fn(), - stop: jest.fn(), - tick: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), - })); - - const app = loadApp(); - const res = await request(app).get('/health'); - - expect(res.body.status).toBe('ok'); - }); - - test('status is unhealthy when Redis is disconnected', async () => { - jest.resetModules(); - - jest.mock('../src/services/cache', () => ({ - isConnected: () => false, - disconnect: jest.fn(), - })); - jest.mock('../src/jobs/priceRefresh', () => ({ - start: jest.fn(), - stop: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), - })); - jest.mock('../src/jobs/webhookRetryWorker', () => ({ - start: jest.fn(), - stop: jest.fn(), - tick: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), - })); - - const app = loadApp(); - const res = await request(app).get('/health'); - - expect(res.body.status).toBe('unhealthy'); - expect(res.body.redis.connected).toBe(false); - }); - - test('status is unhealthy when a job is stalled', async () => { - jest.resetModules(); - - jest.mock('../src/services/cache', () => ({ - isConnected: () => true, - disconnect: jest.fn(), - })); - jest.mock('../src/jobs/priceRefresh', () => ({ - start: jest.fn(), - stop: jest.fn(), - getHealth: () => ({ healthy: false, lastSuccessAt: null, lastError: 'timeout', stalled: true }), - })); - jest.mock('../src/jobs/webhookRetryWorker', () => ({ - start: jest.fn(), - stop: jest.fn(), - tick: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), - })); - - const app = loadApp(); - const res = await request(app).get('/health'); - - expect(res.body.status).toBe('unhealthy'); - expect(res.body.jobs.price_refresh.stalled).toBe(true); - expect(res.body.jobs.price_refresh.last_error).toBe('timeout'); - }); - - test('status is degraded during startup grace period (job not yet run)', async () => { - jest.resetModules(); - - jest.mock('../src/services/cache', () => ({ - isConnected: () => true, - disconnect: jest.fn(), - })); - jest.mock('../src/jobs/priceRefresh', () => ({ - start: jest.fn(), - stop: jest.fn(), - // healthy=false, stalled=false → still in grace period - getHealth: () => ({ healthy: false, lastSuccessAt: null, lastError: null, stalled: false }), - })); - jest.mock('../src/jobs/webhookRetryWorker', () => ({ - start: jest.fn(), - stop: jest.fn(), - tick: jest.fn(), - getHealth: () => ({ healthy: true, lastSuccessAt: Date.now(), lastError: null, stalled: false }), - })); - - const app = loadApp(); - const res = await request(app).get('/health'); - - expect(res.body.status).toBe('degraded'); - }); - - test('overall status is never ok when any dependency is unhealthy', async () => { - jest.resetModules(); - - jest.mock('../src/services/cache', () => ({ - isConnected: () => false, - disconnect: jest.fn(), - })); - jest.mock('../src/jobs/priceRefresh', () => ({ - start: jest.fn(), - stop: jest.fn(), - getHealth: () => ({ healthy: false, lastSuccessAt: null, lastError: 'err', stalled: true }), - })); - jest.mock('../src/jobs/webhookRetryWorker', () => ({ - start: jest.fn(), - stop: jest.fn(), - tick: jest.fn(), - getHealth: () => ({ healthy: false, lastSuccessAt: null, lastError: 'err', stalled: true }), - })); - - const app = loadApp(); - const res = await request(app).get('/health'); - - expect(res.body.status).not.toBe('ok'); - }); -}); - -// --------------------------------------------------------------------------- -// priceRefresh.getHealth() – unit tests for grace-period logic -// --------------------------------------------------------------------------- - -describe('priceRefresh.getHealth() – grace period', () => { - test('returns healthy=false and stalled=false before start() is called', () => { - // Load the real module, bypassing any jest.mock registrations from prior tests - const job = jest.requireActual('../src/jobs/priceRefresh'); - // Reset internal state by reloading via isolateModules - let freshJob; - jest.isolateModules(() => { - jest.unmock('../src/jobs/priceRefresh'); - freshJob = require('../src/jobs/priceRefresh'); - }); - const h = freshJob.getHealth(); - expect(h.healthy).toBe(false); - expect(h.stalled).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// webhookRetryWorker.getHealth() – unit tests for grace-period logic -// --------------------------------------------------------------------------- - -describe('webhookRetryWorker.getHealth() – grace period', () => { - test('returns healthy=false and stalled=false before start() is called', () => { - let freshWorker; - jest.isolateModules(() => { - jest.unmock('../src/jobs/webhookRetryWorker'); - freshWorker = require('../src/jobs/webhookRetryWorker'); - }); - const h = freshWorker.getHealth(); - expect(h.healthy).toBe(false); - expect(h.stalled).toBe(false); - }); -}); diff --git a/test/helpers/cacheMock.js b/test/helpers/cacheMock.js deleted file mode 100644 index 0727344..0000000 --- a/test/helpers/cacheMock.js +++ /dev/null @@ -1,205 +0,0 @@ -'use strict'; - -/** - * In-memory mock of the ioredis surface used by src/services/cache.js. - * Covers strings (used by cache.get/set/del), SETs, sorted SETs, and LISTs. - */ -function createCacheMock() { - const store = new Map(); - const sets = new Map(); - const zsets = new Map(); - const lists = new Map(); - const counters = new Map(); - // Separate raw string store (with per-key TTL) backing redis.set/get/del — - // distinct from `store` above, which backs the higher-level cacheMock. - // get/set JSON API with different key/value semantics. - const rawStore = new Map(); - - function getSet(key) { - if (!sets.has(key)) sets.set(key, new Set()); - return sets.get(key); - } - function getZSet(key) { - if (!zsets.has(key)) zsets.set(key, new Map()); - return zsets.get(key); - } - function getList(key) { - if (!lists.has(key)) lists.set(key, []); - return lists.get(key); - } - function isExpired(entry) { - return entry.expiresAt !== null && Date.now() >= entry.expiresAt; - } - function getLive(key) { - const entry = rawStore.get(key); - if (!entry || isExpired(entry)) return null; - return entry; - } - - const redis = { - smembers: jest.fn(async (key) => [...(sets.get(key) || [])]), - sadd: jest.fn(async (key, val) => { getSet(key).add(val); }), - srem: jest.fn(async (key, val) => { sets.get(key)?.delete(val); }), - zadd: jest.fn(async (key, score, member) => { getZSet(key).set(member, Number(score)); }), - zcard: jest.fn(async (key) => (zsets.get(key) || new Map()).size), - rpush: jest.fn(async (key, ...vals) => { getList(key).push(...vals); }), - llen: jest.fn(async (key) => (lists.get(key) || []).length), - lrange: jest.fn(async (key, start, stop) => { - const list = lists.get(key) || []; - const resolveIndex = (i) => (i < 0 ? Math.max(list.length + i, 0) : i); - return list.slice(resolveIndex(start), resolveIndex(stop) + 1); - }), - zrem: jest.fn(async (key, ...members) => { - const z = zsets.get(key); - if (!z) return; - for (const m of members) z.delete(m); - }), - zrevrange: jest.fn(async (key, start, stop) => { - const z = zsets.get(key); - if (!z) return []; - const sorted = [...z.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m); - // Real Redis treats negative indices as counting from the end - // (-1 = last element) — needed for the common "N to the end" - // idiom (e.g. ZREVRANGE key 0 -1), which plain `slice(start, - // stop + 1)` gets wrong for any negative stop (#131). - const resolveIndex = (i) => (i < 0 ? Math.max(sorted.length + i, 0) : i); - return sorted.slice(resolveIndex(start), resolveIndex(stop) + 1); - }), - zrangebyscore: jest.fn(async (key, min, max, ...rest) => { - const z = zsets.get(key); - if (!z) return []; - const minScore = min === '-inf' ? -Infinity : Number(min); - const maxScore = max === '+inf' ? Infinity : Number(max); - let sorted = [...z.entries()] - .filter(([, score]) => score >= minScore && score <= maxScore) - .sort((a, b) => a[1] - b[1]) - .map(([m]) => m); - const limitIdx = rest.indexOf('LIMIT'); - if (limitIdx !== -1) { - const offset = Number(rest[limitIdx + 1]); - const count = Number(rest[limitIdx + 2]); - sorted = sorted.slice(offset, offset + count); - } - return sorted; - }), - zremrangebyrank: jest.fn(async (key, start, stop) => { - const z = zsets.get(key); - if (!z) return; - const sortedAsc = [...z.entries()].sort((a, b) => a[1] - b[1]).map(([m]) => m); - const end = stop < 0 ? sortedAsc.length + stop : stop; - const begin = start < 0 ? sortedAsc.length + start : start; - for (let i = begin; i <= end && i < sortedAsc.length; i += 1) { - z.delete(sortedAsc[i]); - } - }), - incr: jest.fn(async (key) => { - const n = (counters.get(key) || 0) + 1; - counters.set(key, n); - return n; - }), - expire: jest.fn(async () => 1), - // ioredis-style raw SET, supporting the NX/PX/EX option pairs used by - // leaderElection.js's lease acquisition (`SET key val NX PX ttlMs`). - // Returns 'OK' on success, null if NX and the key already holds a - // live (non-expired) value — matching real Redis's SET NX semantics. - set: jest.fn(async (key, value, ...args) => { - let nx = false; - let ttlMs = null; - for (let i = 0; i < args.length; i += 1) { - const arg = String(args[i]).toUpperCase(); - if (arg === 'NX') nx = true; - else if (arg === 'PX') { ttlMs = Number(args[i + 1]); i += 1; } - else if (arg === 'EX') { ttlMs = Number(args[i + 1]) * 1000; i += 1; } - } - if (nx && getLive(key)) return null; - rawStore.set(key, { value: String(value), expiresAt: ttlMs !== null ? Date.now() + ttlMs : null }); - return 'OK'; - }), - get: jest.fn(async (key) => { - const entry = getLive(key); - return entry ? entry.value : null; - }), - del: jest.fn(async (key) => (rawStore.delete(key) ? 1 : 0)), - pexpire: jest.fn(async (key, ms) => { - const entry = getLive(key); - if (!entry) return 0; - entry.expiresAt = Date.now() + Number(ms); - return 1; - }), - // Mimics ioredis#defineCommand for the custom commands this codebase - // registers (see deliveryRepository.js and leaderElection.js). Real - // Redis runs the Lua body single-threaded to completion, so this mock - // implementation reads and mutates without an intervening `await`, - // preserving that atomicity guarantee for tests. - defineCommand: jest.fn((name, { lua } = {}) => { - if (name === 'popDueRetriesAtomic') { - redis.popDueRetriesAtomic = jest.fn(async (queueKey, maxScore, limit) => { - const z = getZSet(queueKey); - const max = Number(maxScore); - const ids = [...z.entries()] - .filter(([, score]) => score <= max) - .sort((a, b) => a[1] - b[1]) - .slice(0, Number(limit)) - .map(([m]) => m); - ids.forEach((id) => z.delete(id)); - return ids; - }); - return; - } - if (name === 'renewLease') { - // Mirrors RENEW_LUA: renew only if we still hold the lease. - redis.renewLease = jest.fn(async (key, expectedValue, ttlMs) => { - const entry = getLive(key); - if (entry && entry.value === expectedValue) { - entry.expiresAt = Date.now() + Number(ttlMs); - return 1; - } - return 0; - }); - return; - } - if (name === 'releaseLease') { - // Mirrors RELEASE_LUA: release only if we still hold the lease. - redis.releaseLease = jest.fn(async (key, expectedValue) => { - const entry = getLive(key); - if (entry && entry.value === expectedValue) { - rawStore.delete(key); - return 1; - } - return 0; - }); - return; - } - throw new Error(`cacheMock.defineCommand: unsupported command "${name}" (lua: ${typeof lua})`); - }), - }; - - const cacheMock = { - getClient: () => redis, - isConnected: () => true, - get: jest.fn(async (key) => { - const v = store.get(key); - return v !== undefined ? JSON.parse(JSON.stringify(v)) : null; - }), - set: jest.fn(async (key, value) => { store.set(key, JSON.parse(JSON.stringify(value))); }), - del: jest.fn(async (key) => { store.delete(key); }), - disconnect: jest.fn(async () => {}), - }; - - function reset() { - store.clear(); - sets.clear(); - zsets.clear(); - lists.clear(); - counters.clear(); - rawStore.clear(); - Object.values(redis).forEach((fn) => fn.mockClear?.()); - cacheMock.get.mockClear(); - cacheMock.set.mockClear(); - cacheMock.del.mockClear(); - } - - return { cacheMock, redis, store, sets, zsets, lists, counters, reset }; -} - -module.exports = { createCacheMock }; diff --git a/test/indexerParser.test.js b/test/indexerParser.test.js deleted file mode 100644 index cd4598e..0000000 --- a/test/indexerParser.test.js +++ /dev/null @@ -1,93 +0,0 @@ -'use strict'; - -const { nativeToScVal, xdr } = require('stellar-sdk'); -const { EVENT_NAMES, parseContractEvent } = require('../src/indexer/eventParser'); - -function sym(value) { - return nativeToScVal(value, { type: 'symbol' }); -} - -function scVal(value) { - if (Array.isArray(value)) return xdr.ScVal.scvVec(value.map(scVal)); - return nativeToScVal(value); -} - -function topic(value) { - return typeof value === 'string' && EVENT_NAMES.includes(value) ? sym(value) : scVal(value); -} - -function event(topics, value, overrides = {}) { - return { - id: overrides.id || 'evt-1', - type: 'contract', - ledger: overrides.ledger || 123, - ledgerClosedAt: '2026-06-25T00:00:00Z', - pagingToken: '123-1', - inSuccessfulContractCall: true, - topic: topics.map(topic), - value: scVal(value), - ...overrides, - }; -} - -describe('Soroban contract event parser', () => { - test('decodes airdrop_created events with full array payload', () => { - const parsed = parseContractEvent(event(['airdrop_created'], [ - 'drop-1', - 'GCREATOR11111111111111111111111111111111111111111111111', - 'USDC', - 1000n, - 456n, - ])); - - expect(parsed.event_name).toBe('airdrop_created'); - expect(parsed.data).toMatchObject({ - airdrop_id: 'drop-1', - creator: 'GCREATOR11111111111111111111111111111111111111111111111', - token: 'USDC', - total_amount: '1000', - expiry_ledger: '456', - }); - expect(parsed.raw_xdr.value).toEqual(expect.any(String)); - }); - - test('uses topic hints when IDs are emitted as topics', () => { - const parsed = parseContractEvent(event(['recipient_added', 'drop-1', 'GRECIPIENT1111111111111111111111111111111111111111111'], [250n])); - - expect(parsed.event_name).toBe('recipient_added'); - expect(parsed.data).toMatchObject({ - airdrop_id: 'drop-1', - recipient: 'GRECIPIENT1111111111111111111111111111111111111111111', - amount: '250', - }); - }); - - test('decodes token_claimed events with object payloads', () => { - const parsed = parseContractEvent(event(['token_claimed'], { - airdrop_id: 'drop-1', - recipient: 'GRECIPIENT1111111111111111111111111111111111111111111', - amount: 125n, - ledger: 789n, - })); - - expect(parsed.data).toMatchObject({ - airdrop_id: 'drop-1', - amount: '125', - ledger: '789', - }); - }); - - test('decodes airdrop_expired events', () => { - const parsed = parseContractEvent(event(['airdrop_expired', 'drop-1'], [875n])); - - expect(parsed.event_name).toBe('airdrop_expired'); - expect(parsed.data).toMatchObject({ - airdrop_id: 'drop-1', - unclaimed_amount: '875', - }); - }); - - test('ignores unsupported contract events', () => { - expect(parseContractEvent(event(['unrelated_event'], ['drop-1']))).toBeNull(); - }); -}); diff --git a/test/indexerRoutes.test.js b/test/indexerRoutes.test.js deleted file mode 100644 index 1102c4d..0000000 --- a/test/indexerRoutes.test.js +++ /dev/null @@ -1,125 +0,0 @@ -'use strict'; - -const express = require('express'); -const request = require('supertest'); - -const mockGetAirdropStatus = jest.fn(); -const mockGetAirdropRecipients = jest.fn(); -const mockGetRecipientClaims = jest.fn(); -const mockGetStats = jest.fn(); - -jest.mock('../src/indexer/eventStore', () => ({ - getAirdropStatus: mockGetAirdropStatus, - getAirdropRecipients: mockGetAirdropRecipients, - getRecipientClaims: mockGetRecipientClaims, - getStats: mockGetStats, -})); - -jest.mock('../src/indexer/runtime', () => ({ - getStatus: jest.fn(() => ({ - enabled: true, - configured: true, - running: true, - contract_id: 'CCONTRACT', - poll_interval_ms: 5000, - poll_limit: 100, - last_run: '2026-06-25T00:00:00.000Z', - last_error: null, - })), -})); - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -const indexerRouter = require('../src/routes/indexer'); - -function buildApp() { - const app = express(); - app.use('/api/v1', indexerRouter); - return app; -} - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('indexer routes', () => { - test('returns indexed airdrop status', async () => { - mockGetAirdropStatus.mockResolvedValue({ - airdrop_id: 'drop-1', - status: 'created', - recipients_count: 2, - }); - - const res = await request(buildApp()).get('/api/v1/airdrops/drop-1/status'); - - expect(res.status).toBe(200); - expect(res.body).toMatchObject({ airdrop_id: 'drop-1', status: 'created' }); - }); - - test('returns 404 for unknown airdrop status', async () => { - mockGetAirdropStatus.mockResolvedValue(null); - - const res = await request(buildApp()).get('/api/v1/airdrops/missing/status'); - - expect(res.status).toBe(404); - }); - - test('returns indexed recipients in the canonical pagination envelope (#131)', async () => { - mockGetAirdropRecipients.mockResolvedValue([{ recipient: 'GRECIPIENT', status: 'claimed' }]); - - const res = await request(buildApp()).get('/api/v1/airdrops/drop-1/onchain-recipients'); - - expect(res.status).toBe(200); - expect(res.body.data).toHaveLength(1); - expect(res.body.pagination).toMatchObject({ page: 1, limit: 20, total: 1 }); - }); - - test('returns recipient claims in the canonical pagination envelope (#131)', async () => { - mockGetRecipientClaims.mockResolvedValue([{ airdrop_id: 'drop-1', amount: '25' }]); - - const res = await request(buildApp()).get('/api/v1/recipients/GRECIPIENT12345/claims'); - - expect(res.status).toBe(200); - expect(res.body.data).toEqual([{ airdrop_id: 'drop-1', amount: '25' }]); - expect(res.body.pagination).toMatchObject({ page: 1, limit: 20, total: 1 }); - }); - - test('paginates recipient claims with page/limit query params (#131)', async () => { - mockGetRecipientClaims.mockResolvedValue( - Array.from({ length: 3 }, (_, i) => ({ airdrop_id: `drop-${i}`, amount: '1' })), - ); - - const res = await request(buildApp()).get( - '/api/v1/recipients/GRECIPIENT12345/claims?page=1&limit=2', - ); - - expect(res.status).toBe(200); - expect(res.body.data).toHaveLength(2); - expect(res.body.pagination).toMatchObject({ - page: 1, - limit: 2, - total: 3, - total_pages: 2, - has_next: true, - has_prev: false, - }); - }); - - test('returns indexer status with ledger and event counts', async () => { - mockGetStats.mockResolvedValue({ last_ledger: 42, events_count: 7 }); - - const res = await request(buildApp()).get('/api/v1/indexer/status'); - - expect(res.status).toBe(200); - expect(res.body).toMatchObject({ - configured: true, - last_ledger: 42, - events_count: 7, - }); - }); -}); diff --git a/test/indexerStore.test.js b/test/indexerStore.test.js deleted file mode 100644 index fde01bc..0000000 --- a/test/indexerStore.test.js +++ /dev/null @@ -1,115 +0,0 @@ -'use strict'; - -const mockStore = new Map(); -const mockSets = new Map(); - -const mockRedis = { - smembers: jest.fn(async (key) => [...(mockSets.get(key) || [])]), - sadd: jest.fn(async (key, val) => { - if (!mockSets.has(key)) mockSets.set(key, new Set()); - mockSets.get(key).add(val); - }), -}; - -jest.mock('../src/services/cache', () => ({ - getClient: () => mockRedis, - get: jest.fn(async (key) => { - const value = mockStore.get(key); - return value !== undefined ? JSON.parse(JSON.stringify(value)) : null; - }), - set: jest.fn(async (key, value) => { - mockStore.set(key, JSON.parse(JSON.stringify(value))); - }), - del: jest.fn(async (key) => { - mockStore.delete(key); - }), -})); - -const eventStore = require('../src/indexer/eventStore'); - -function baseEvent(overrides) { - return { - id: overrides.id, - event_name: overrides.event_name, - ledger: overrides.ledger || 100, - ledger_closed_at: '2026-06-25T00:00:00Z', - data: overrides.data, - }; -} - -beforeEach(() => { - mockStore.clear(); - mockSets.clear(); - mockRedis.smembers.mockClear(); - mockRedis.sadd.mockClear(); -}); - -describe('indexer event store', () => { - test('persists airdrop lifecycle, recipients, claims, and stats', async () => { - await eventStore.saveEvent(baseEvent({ - id: 'evt-created', - event_name: 'airdrop_created', - ledger: 10, - data: { - airdrop_id: 'drop-1', - creator: 'GCREATOR', - token: 'USDC', - total_amount: '1000', - expiry_ledger: '500', - }, - })); - await eventStore.saveEvent(baseEvent({ - id: 'evt-recipient', - event_name: 'recipient_added', - ledger: 11, - data: { - airdrop_id: 'drop-1', - recipient: 'GRECIPIENT', - amount: '250', - }, - })); - await eventStore.saveEvent(baseEvent({ - id: 'evt-claim', - event_name: 'token_claimed', - ledger: 12, - data: { - airdrop_id: 'drop-1', - recipient: 'GRECIPIENT', - amount: '250', - ledger: '12', - }, - })); - await eventStore.saveEvent(baseEvent({ - id: 'evt-expired', - event_name: 'airdrop_expired', - ledger: 13, - data: { - airdrop_id: 'drop-1', - unclaimed_amount: '750', - }, - })); - await eventStore.setLastLedger(13); - - const status = await eventStore.getAirdropStatus('drop-1'); - expect(status).toMatchObject({ - airdrop_id: 'drop-1', - status: 'expired', - total_amount: '1000', - recipients_count: 1, - claimed_count: 1, - pending_count: 0, - unclaimed_amount: '750', - }); - - await expect(eventStore.getAirdropRecipients('drop-1')).resolves.toEqual([ - expect.objectContaining({ recipient: 'GRECIPIENT', status: 'claimed', amount: '250' }), - ]); - await expect(eventStore.getRecipientClaims('GRECIPIENT')).resolves.toEqual([ - expect.objectContaining({ event_id: 'evt-claim', airdrop_id: 'drop-1', amount: '250' }), - ]); - await expect(eventStore.getStats()).resolves.toMatchObject({ - last_ledger: 13, - events_count: 4, - }); - }); -}); diff --git a/test/jest-e2e.json b/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/test/leaderElection.test.js b/test/leaderElection.test.js deleted file mode 100644 index b0e79a4..0000000 --- a/test/leaderElection.test.js +++ /dev/null @@ -1,451 +0,0 @@ -'use strict'; - -/** - * Leader Election Tests - * - * Tests the Redis-based leader election mechanism with multi-instance - * simulation, failover scenarios, and log observability. - * - * Uses the in-memory cache mock from test/helpers/cacheMock.js so tests - * run without a real Redis instance. - */ - -const { createCacheMock: mockCreateCacheMock } = require('./helpers/cacheMock'); -const { createLeaderElection } = require('../src/services/leaderElection'); - -// We need to override the cache module before requiring leaderElection. -// The factory below may only reference identifiers Jest's mock-hoisting -// considers safe (globals, or names prefixed with "mock") — hence the -// renamed import above instead of the plain `createCacheMock`. -jest.mock('../src/services/cache', () => { - const mock = mockCreateCacheMock(); - // Store reference for test access - global.__cacheMock__ = mock; - return mock.cacheMock; -}); - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -jest.mock('../src/config', () => ({ - leaderElection: { - instanceId: 'test-instance-001', - leaseTtlMs: 500, - renewIntervalMs: 200, - }, -})); - -const logger = require('../src/logger'); - -// Helper: advance time by a given number of ms using jest's fake timers -jest.useFakeTimers(); - -describe('Leader Election', () => { - let cacheMock; - let redis; - let leaderElection; - - beforeEach(() => { - jest.clearAllMocks(); - jest.clearAllTimers(); - - cacheMock = global.__cacheMock__; - cacheMock.reset(); - redis = cacheMock.redis; - - leaderElection = createLeaderElection('test_job', { - instanceId: 'test-instance-001', - leaseTtlMs: 500, - renewIntervalMs: 200, - }); - }); - - afterEach(() => { - leaderElection.stopRenewLoop(); - }); - - /* ------------------------------------------------------------------ */ - /* Basic lock acquisition and release */ - /* ------------------------------------------------------------------ */ - - describe('lock acquisition and release', () => { - test('tryAcquire returns true when no one holds the lock', async () => { - const result = await leaderElection.tryAcquire(); - expect(result).toBe(true); - expect(leaderElection.isLeader()).toBe(true); - }); - - test('tryAcquire returns false when another instance holds the lock', async () => { - // First instance acquires - const result1 = await leaderElection.tryAcquire(); - expect(result1).toBe(true); - - // Second instance tries to acquire - const leaderElection2 = createLeaderElection('test_job', { - instanceId: 'test-instance-002', - leaseTtlMs: 500, - renewIntervalMs: 200, - }); - - const result2 = await leaderElection2.tryAcquire(); - expect(result2).toBe(false); - expect(leaderElection2.isLeader()).toBe(false); - - leaderElection2.stopRenewLoop(); - }); - - test('getCurrentLeader returns the instance id of the lock holder', async () => { - await leaderElection.tryAcquire(); - const current = await leaderElection.getCurrentLeader(); - expect(current).toBe('test-instance-001'); - }); - - test('stopRenewLoop releases the lock', async () => { - await leaderElection.tryAcquire(); - expect(leaderElection.isLeader()).toBe(true); - - await leaderElection.stopRenewLoop(); - expect(leaderElection.isLeader()).toBe(false); - - const current = await leaderElection.getCurrentLeader(); - expect(current).toBeNull(); - }); - - test('getState returns correct diagnostic info', async () => { - await leaderElection.tryAcquire(); - const state = leaderElection.getState(); - expect(state.isLeader).toBe(true); - expect(state.instanceId).toBe('test-instance-001'); - expect(state.lockKey).toBe('leader:test_job'); - expect(state.leaseTtlMs).toBe(500); - expect(state.renewIntervalMs).toBe(200); - expect(state.acquiredAt).toBeTruthy(); - expect(state.lastRenewedAt).toBeTruthy(); - }); - }); - - /* ------------------------------------------------------------------ */ - /* Lease renewal */ - /* ------------------------------------------------------------------ */ - - describe('lease renewal', () => { - test('renew() successfully extends the lease when we hold it', async () => { - await leaderElection.tryAcquire(); - expect(leaderElection.isLeader()).toBe(true); - - // Manually advance time to simulate lease expiry approach - const result = await leaderElection.renew(); - expect(result).toBe(true); - expect(leaderElection.isLeader()).toBe(true); - }); - - test('renew() returns false and clears leader when lease is lost', async () => { - await leaderElection.tryAcquire(); - expect(leaderElection.isLeader()).toBe(true); - - // Simulate someone else taking the lock (direct Redis manipulation). - // Same shared in-memory client as `redis` above — there's only one - // Redis (real or mocked) for every instance to contend over. - await redis.set('leader:test_job', 'test-instance-002', 'PX', 500); - - const result = await leaderElection.renew(); - expect(result).toBe(false); - expect(leaderElection.isLeader()).toBe(false); - }); - }); - - /* ------------------------------------------------------------------ */ - /* Multi-instance simulation (2+ concurrent "instances") */ - /* ------------------------------------------------------------------ */ - - describe('multi-instance simulation', () => { - test('only one out of 3 instances holds leadership at a time', async () => { - const instances = []; - const NUM_INSTANCES = 3; - - // Create 3 leader election instances - for (let i = 0; i < NUM_INSTANCES; i++) { - const inst = createLeaderElection('multi_test', { - instanceId: `instance-${String(i).padStart(3, '0')}`, - leaseTtlMs: 500, - renewIntervalMs: 200, - }); - instances.push(inst); - } - - // All try to acquire simultaneously - const results = await Promise.all(instances.map((inst) => inst.tryAcquire())); - - // Exactly one should succeed - const leaders = results.filter((r) => r === true); - expect(leaders.length).toBe(1); - - // The leader should report isLeader() === true - const leaderIndex = results.indexOf(true); - expect(instances[leaderIndex].isLeader()).toBe(true); - - // All others should report isLeader() === false - for (let i = 0; i < NUM_INSTANCES; i++) { - if (i !== leaderIndex) { - expect(instances[i].isLeader()).toBe(false); - } - } - - // Cleanup - await Promise.all(instances.map((inst) => inst.stopRenewLoop())); - }); - - test('only one instance tick function executes when wrapped via leaderAwareJob', async () => { - const { makeLeaderAwareJob } = require('../src/jobs/leaderAwareJob'); - - // Create a mock job that records how many times it's started - const mockJob = { - start: jest.fn(), - stop: jest.fn(), - getHealth: jest.fn(() => ({ - healthy: true, - lastSuccessAt: Date.now(), - lastError: null, - stalled: false, - })), - }; - - const instances = []; - const NUM_INSTANCES = 3; - - // Create multiple leader-aware wrapped jobs - for (let i = 0; i < NUM_INSTANCES; i++) { - const le = createLeaderElection('aware_test', { - instanceId: `aware-instance-${String(i).padStart(3, '0')}`, - leaseTtlMs: 500, - renewIntervalMs: 200, - }); - - const wrapped = makeLeaderAwareJob({ - job: { - start: jest.fn(), - stop: jest.fn(), - getHealth: mockJob.getHealth, - }, - jobName: 'aware_test', - leaderElection: le, - logger, - }); - - instances.push({ le, wrapped }); - } - - // Start all wrapped jobs (they'll each start their renewal loops) - for (const { wrapped } of instances) { - wrapped.start(); - } - - // Let initial acquisition happen - await jest.advanceTimersByTimeAsync(100); - - // Count how many underlying jobs actually started - const startedCount = instances.filter(({ wrapped }) => { - const health = wrapped.getHealth(); - return health.leader === true; - }).length; - - expect(startedCount).toBe(1); - - // Cleanup - for (const { wrapped } of instances) { - await wrapped.stop(); - } - }); - }); - - /* ------------------------------------------------------------------ */ - /* Kill-the-leader test (simulate crash, verify failover) */ - /* ------------------------------------------------------------------ */ - - describe('kill-the-leader failover', () => { - test('follower acquires leadership after leader lease expires', async () => { - // Leader instance - const leader = createLeaderElection('failover_test', { - instanceId: 'leader-instance', - leaseTtlMs: 300, - renewIntervalMs: 100, - }); - - // Follower instance - const follower = createLeaderElection('failover_test', { - instanceId: 'follower-instance', - leaseTtlMs: 300, - renewIntervalMs: 100, - }); - - // Leader acquires - const leaderResult = await leader.tryAcquire(); - expect(leaderResult).toBe(true); - expect(leader.isLeader()).toBe(true); - - // Follower fails to acquire - const followerResult = await follower.tryAcquire(); - expect(followerResult).toBe(false); - expect(follower.isLeader()).toBe(false); - - // "Kill" the leader by stopping its renewal loop (simulates crash) - await leader.stopRenewLoop(); - expect(leader.isLeader()).toBe(false); - - // Wait for lease TTL to expire + some buffer - await jest.advanceTimersByTimeAsync(500); - - // Follower should now be able to acquire - const followerResult2 = await follower.tryAcquire(); - expect(followerResult2).toBe(true); - expect(follower.isLeader()).toBe(true); - - // Verify the lock key now holds follower's id - const current = await follower.getCurrentLeader(); - expect(current).toBe('follower-instance'); - - follower.stopRenewLoop(); - }); - - test('renewal loop detects and re-acquires leadership after leader crash', async () => { - // This simulates the full renewal loop behavior - const leader = createLeaderElection('renewal_failover', { - instanceId: 'renewal-leader', - leaseTtlMs: 300, - renewIntervalMs: 100, - }); - - const follower = createLeaderElection('renewal_failover', { - instanceId: 'renewal-follower', - leaseTtlMs: 300, - renewIntervalMs: 100, - }); - - // Start both renewal loops - leader.startRenewLoop(); - follower.startRenewLoop(); - - // Allow initial acquisition - await jest.advanceTimersByTimeAsync(50); - - // Leader should have the lock - expect(leader.isLeader()).toBe(true); - expect(follower.isLeader()).toBe(false); - - // "Kill" leader - await leader.stopRenewLoop(); - - // Wait for lease expiry + follower renewal cycle - await jest.advanceTimersByTimeAsync(600); - - // Follower should have acquired the lock - expect(follower.isLeader()).toBe(true); - - follower.stopRenewLoop(); - }); - }); - - /* ------------------------------------------------------------------ */ - /* Graceful handoff test (release lock on stop) */ - /* ------------------------------------------------------------------ */ - - describe('graceful handoff', () => { - test('stopRenewLoop releases lease immediately so follower can take over', async () => { - const leader = createLeaderElection('handoff_test', { - instanceId: 'handoff-leader', - leaseTtlMs: 10000, // Long TTL to prove we don't wait for expiry - renewIntervalMs: 5000, - }); - - const follower = createLeaderElection('handoff_test', { - instanceId: 'handoff-follower', - leaseTtlMs: 10000, - renewIntervalMs: 5000, - }); - - // Leader acquires - await leader.tryAcquire(); - expect(leader.isLeader()).toBe(true); - - // Follower fails - const followerResult = await follower.tryAcquire(); - expect(followerResult).toBe(false); - - // Leader gracefully releases (simulates SIGTERM) - await leader.stopRenewLoop(); - expect(leader.isLeader()).toBe(false); - - // Follower should immediately acquire (no need to wait for TTL) - const followerResult2 = await follower.tryAcquire(); - expect(followerResult2).toBe(true); - expect(follower.isLeader()).toBe(true); - - follower.stopRenewLoop(); - }); - }); - - /* ------------------------------------------------------------------ */ - /* Log observability test */ - /* ------------------------------------------------------------------ */ - - describe('log observability', () => { - test('acquiring leadership logs a clear message', async () => { - await leaderElection.tryAcquire(); - expect(logger.info).toHaveBeenCalledWith( - expect.stringMatching(/Acquired leader lease/i), - expect.objectContaining({ - job: 'test_job', - instanceId: 'test-instance-001', - }), - ); - }); - - test('releasing leadership logs a clear message', async () => { - await leaderElection.tryAcquire(); - jest.clearAllMocks(); - await leaderElection.stopRenewLoop(); - expect(logger.info).toHaveBeenCalledWith( - expect.stringMatching(/Released leader lease/i), - expect.objectContaining({ - job: 'test_job', - instanceId: 'test-instance-001', - }), - ); - }); - - test('renewal loop started logs a clear message', () => { - leaderElection.startRenewLoop(); - expect(logger.info).toHaveBeenCalledWith( - expect.stringMatching(/Leader election renewal loop started/i), - expect.objectContaining({ - job: 'test_job', - instanceId: 'test-instance-001', - }), - ); - }); - - test('failing to acquire as follower logs acting as follower via health state', async () => { - // First instance acquires - await leaderElection.tryAcquire(); - - // Second instance tries - const leaderElection2 = createLeaderElection('test_job', { - instanceId: 'test-instance-002', - leaseTtlMs: 500, - renewIntervalMs: 200, - }); - - const result = await leaderElection2.tryAcquire(); - expect(result).toBe(false); - expect(leaderElection2.isLeader()).toBe(false); - expect(leaderElection2.getState().isLeader).toBe(false); - - leaderElection2.stopRenewLoop(); - }); - }); -}); - diff --git a/test/pagination.test.js b/test/pagination.test.js deleted file mode 100644 index 1e5ac91..0000000 --- a/test/pagination.test.js +++ /dev/null @@ -1,52 +0,0 @@ -const { parsePagination } = require('../src/utils/paginate'); - - -describe('parsePagination', () => { - - test('page=0 should clamp to 1', () => { - - const result = parsePagination({ - page:'0' - }); - - expect(result.page).toBe(1); - - }); - - - test('limit=0 should use default limit', () => { - - const result = parsePagination({ - limit:'0' - }); - - expect(result.limit).toBe(20); - - }); - - - test('limit above max should clamp to 100', () => { - - const result = parsePagination({ - limit:'9999' - }); - - expect(result.limit).toBe(100); - - }); - - - test('non integer values should use defaults', () => { - - const result = parsePagination({ - page:'abc', - limit:'hello' - }); - - - expect(result.page).toBe(1); - expect(result.limit).toBe(20); - - }); - -}); \ No newline at end of file diff --git a/test/paginationContract.test.js b/test/paginationContract.test.js deleted file mode 100644 index 327d05e..0000000 --- a/test/paginationContract.test.js +++ /dev/null @@ -1,160 +0,0 @@ -'use strict'; - -/** - * Contract test for #131: every list endpoint in the public API must - * return the same pagination envelope shape — - * src/schemas/pagination.js's paginatedResponseSchema, picked as - * canonical since it's the shape already partially in use (GET /alerts) - * and already defined in code, just not consistently applied. - * - * Boots the real app (mirroring test/airdrops.test.js's pattern) with - * only cache/logger/stellar-sdk/eventStore mocked, and validates each - * list endpoint's actual response against the schema directly — - * matching the issue's own suggested test plan - * (`paginatedResponseSchema.safeParse(response.body)`) rather than - * asserting on individual fields, so a future endpoint that - * reintroduces a sixth shape fails here regardless of which field it - * gets wrong. - */ - -const { createCacheMock } = require('./helpers/cacheMock'); -const mockCacheHelper = createCacheMock(); - -jest.mock('../src/services/cache', () => mockCacheHelper.cacheMock); - -jest.mock('../src/logger', () => ({ - info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), -})); - -const mockLedger = { sequence: 12345 }; -jest.mock('stellar-sdk', () => ({ - Horizon: { - Server: jest.fn(() => ({ - ledgers: jest.fn(() => ({ - order: jest.fn(() => ({ - limit: jest.fn(() => ({ - call: jest.fn(async () => ({ records: [mockLedger] })), - })), - })), - })), - })), - }, - StrKey: { - isValidEd25519PublicKey: jest.fn((address) => address.startsWith('G') && address.length === 56), - }, - SorobanRpc: { - Server: jest.fn(() => ({})), - }, -})); - -const mockGetAirdropRecipients = jest.fn(); -const mockGetRecipientClaims = jest.fn(); -jest.mock('../src/indexer/eventStore', () => ({ - getAirdropStatus: jest.fn(), - getAirdropRecipients: mockGetAirdropRecipients, - getRecipientClaims: mockGetRecipientClaims, - getStats: jest.fn(async () => ({ last_ledger: 0, events_count: 0 })), -})); - -jest.mock('../src/indexer/runtime', () => ({ - start: jest.fn(), - getStatus: jest.fn(() => ({ - enabled: false, configured: false, running: false, contract_id: null, - poll_interval_ms: 5000, poll_limit: 100, last_run: null, last_error: null, - latest_ledger: null, - })), -})); - -const adminApiKey = 'a'.repeat(64); -process.env.ADMIN_API_KEY = adminApiKey; - -const request = require('supertest'); -const { paginatedResponseSchema } = require('../src/schemas/pagination'); - -let app; - -beforeAll(() => { - app = require('../src/index').app; -}); - -beforeEach(() => { - mockCacheHelper.reset(); - mockGetAirdropRecipients.mockReset(); - mockGetRecipientClaims.mockReset(); -}); - -function expectValidPaginationEnvelope(body) { - const result = paginatedResponseSchema.safeParse(body); - if (!result.success) { - throw new Error( - `Response does not match the canonical pagination envelope: ${JSON.stringify(result.error.issues)}\n` + - `Received: ${JSON.stringify(body)}`, - ); - } -} - -async function createAirdrop() { - const res = await request(app).post('/api/v1/airdrops').send({ - name: 'Contract test airdrop', - asset: 'USDC', - asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', - total_amount: 100, - expiry_ledger: 123456, - }); - return res.body.id; -} - -describe('pagination envelope contract (#131)', () => { - test('GET /airdrops matches the canonical envelope', async () => { - await createAirdrop(); - const res = await request(app).get('/api/v1/airdrops'); - expect(res.status).toBe(200); - expectValidPaginationEnvelope(res.body); - }); - - test('GET /airdrops/:id/recipients matches the canonical envelope', async () => { - const airdropId = await createAirdrop(); - const validAddress = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA'; - await request(app) - .post(`/api/v1/airdrops/${airdropId}/recipients`) - .send({ recipients: [{ address: validAddress, amount: 50 }] }); - - const res = await request(app).get(`/api/v1/airdrops/${airdropId}/recipients`); - expect(res.status).toBe(200); - expectValidPaginationEnvelope(res.body); - }); - - test('GET /webhooks matches the canonical envelope', async () => { - await request(app).post('/api/v1/webhooks').send({ - url: 'https://example.com/hook', - events: ['*'], - secret: 'whsec_aaaaaaaaaaaaaaaa', - }); - - const res = await request(app).get('/api/v1/webhooks'); - expect(res.status).toBe(200); - expectValidPaginationEnvelope(res.body); - }); - - test('GET /alerts matches the canonical envelope', async () => { - const res = await request(app) - .get('/api/v1/alerts') - .set('Authorization', `Bearer ${adminApiKey}`); - expect(res.status).toBe(200); - expectValidPaginationEnvelope(res.body); - }); - - test('GET /airdrops/:id/onchain-recipients matches the canonical envelope', async () => { - mockGetAirdropRecipients.mockResolvedValue([{ recipient: 'GRECIPIENT', status: 'claimed' }]); - const res = await request(app).get('/api/v1/airdrops/drop-1/onchain-recipients'); - expect(res.status).toBe(200); - expectValidPaginationEnvelope(res.body); - }); - - test('GET /recipients/:address/claims matches the canonical envelope', async () => { - mockGetRecipientClaims.mockResolvedValue([{ airdrop_id: 'drop-1', amount: '25' }]); - const res = await request(app).get('/api/v1/recipients/GRECIPIENT12345/claims'); - expect(res.status).toBe(200); - expectValidPaginationEnvelope(res.body); - }); -}); diff --git a/test/priceOracle.test.js b/test/priceOracle.test.js deleted file mode 100644 index c68279e..0000000 --- a/test/priceOracle.test.js +++ /dev/null @@ -1,493 +0,0 @@ -'use strict'; - -// Unit tests for the price oracle's core business logic: median aggregation, -// temporal anomaly detection, multi-source fan-out, and the cache hit/miss -// paths of getPrice/fetchFreshPrice/refreshAllCachedPrices. -// -// NOTE ON BEHAVIOUR: detectAnomaly compares the current aggregated price -// against the *previously cached aggregate over time* and only logs a warning. -// It does NOT exclude an outlier source from the median, and fetchFreshPrice -// ignores its return value. These tests document that actual behaviour rather -// than an assumed cross-source outlier-rejection scheme. - -const mockCacheGet = jest.fn(); -const mockCacheSet = jest.fn(); -const mockCacheIsConnected = jest.fn(); -const mockCacheGetClient = jest.fn(); - -const mockStellarFetch = jest.fn(); -const mockCoingeckoFetch = jest.fn(); -const mockCoinmarketcapFetch = jest.fn(); - -const mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -}; - -jest.mock('../src/services/cache', () => ({ - get: mockCacheGet, - set: mockCacheSet, - isConnected: mockCacheIsConnected, - getClient: mockCacheGetClient, -})); - -jest.mock('../src/services/sources/stellarDex', () => ({ fetchPrice: mockStellarFetch })); -jest.mock('../src/services/sources/coingecko', () => ({ fetchPrice: mockCoingeckoFetch })); -jest.mock('../src/services/sources/coinmarketcap', () => ({ fetchPrice: mockCoinmarketcapFetch })); - -jest.mock('../src/config', () => ({ - price: { - cacheTtl: 60, - refreshInterval: 30, - staleThresholdMinutes: 5, - anomalyThresholdPercent: 20, - }, -})); - -jest.mock('../src/logger', () => mockLogger); - -const oracle = require('../src/services/priceOracle'); - -beforeEach(() => { - mockCacheGet.mockReset(); - mockCacheSet.mockReset(); - mockCacheIsConnected.mockReset(); - mockCacheGetClient.mockReset(); - mockStellarFetch.mockReset(); - mockCoingeckoFetch.mockReset(); - mockCoinmarketcapFetch.mockReset(); - Object.values(mockLogger).forEach((fn) => fn.mockClear()); - - // Sensible defaults: cache writes succeed, cache empty unless a test says otherwise. - mockCacheGet.mockResolvedValue(null); - mockCacheSet.mockResolvedValue(undefined); -}); - -describe('median', () => { - const { median } = oracle; - - test('returns null for an empty array', () => { - expect(median([])).toBeNull(); - }); - - test('returns the single value for a one-element array', () => { - expect(median([5])).toBe(5); - }); - - test('averages the middle two for an even-length array', () => { - expect(median([1, 3])).toBe(2); - expect(median([4, 1, 3, 2])).toBe(2.5); - }); - - test('returns the middle value for an odd-length array', () => { - expect(median([1, 2, 3])).toBe(2); - }); - - test('does not mutate the input array (sorts a copy)', () => { - const input = [3, 1, 2]; - median(input); - expect(input).toEqual([3, 1, 2]); - }); - - test('sorts numerically, not lexicographically', () => { - // Lexicographic sort would order these as [10, 100, 9] and return 100. - expect(median([9, 10, 100])).toBe(10); - }); - - test('resists a single outlier print across three sources', () => { - // The median is naturally robust to one bad print even though no source - // is explicitly excluded. - expect(median([1.0, 1.01, 50])).toBe(1.01); - }); -}); - -describe('detectAnomaly', () => { - const { detectAnomaly } = oracle; - const ASSET = 'XLM'; - - test('stores the price and returns false when no history exists', async () => { - mockCacheGet.mockResolvedValueOnce(null); - - const result = await detectAnomaly(0.1, ASSET, null); - - expect(result).toBe(false); - expect(mockCacheSet).toHaveBeenCalledWith( - 'price:history:XLM', - expect.objectContaining({ price: 0.1 }), - 3600 - ); - }); - - test('treats a non-positive cached price as no history', async () => { - mockCacheGet.mockResolvedValueOnce({ price: 0, timestamp: Date.now() }); - - const result = await detectAnomaly(0.1, ASSET, null); - - expect(result).toBe(false); - expect(mockCacheSet).toHaveBeenCalledWith( - 'price:history:XLM', - expect.objectContaining({ price: 0.1 }), - 3600 - ); - }); - - test('returns false for a change below the threshold', async () => { - mockCacheGet.mockResolvedValueOnce({ price: 1.0, timestamp: Date.now() }); - - const result = await detectAnomaly(1.1, ASSET, null); // +10%, threshold 20 - - expect(result).toBe(false); - expect(mockLogger.warn).not.toHaveBeenCalled(); - }); - - test('returns false at exactly the threshold (strict greater-than boundary)', async () => { - mockCacheGet.mockResolvedValueOnce({ price: 1.0, timestamp: Date.now() }); - - const result = await detectAnomaly(1.2, ASSET, null); // exactly +20% - - expect(result).toBe(false); - expect(mockLogger.warn).not.toHaveBeenCalled(); - }); - - test('logs a warning and returns true just past the threshold', async () => { - mockCacheGet.mockResolvedValueOnce({ price: 1.0, timestamp: Date.now() }); - - const result = await detectAnomaly(1.21, ASSET, null); // +21% - - expect(result).toBe(true); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Price anomaly detected', - expect.objectContaining({ assetCode: ASSET, previousPrice: 1.0, currentPrice: 1.21 }) - ); - }); - - test('detects anomalies symmetrically on a downward move', async () => { - mockCacheGet.mockResolvedValueOnce({ price: 1.0, timestamp: Date.now() }); - - const result = await detectAnomaly(0.5, ASSET, null); // -50% - - expect(result).toBe(true); - }); - - test('re-stores the current price even when an anomaly fires', async () => { - mockCacheGet.mockResolvedValueOnce({ price: 1.0, timestamp: Date.now() }); - - await detectAnomaly(2.0, ASSET, null); - - expect(mockCacheSet).toHaveBeenCalledWith( - 'price:history:XLM', - expect.objectContaining({ price: 2.0 }), - 3600 - ); - }); - - test('uses an issuer-scoped history key when an issuer is provided', async () => { - mockCacheGet.mockResolvedValueOnce(null); - - await detectAnomaly(1.0, 'USDC', 'GISSUER'); - - expect(mockCacheGet).toHaveBeenCalledWith('price:history:USDC:GISSUER'); - }); - - test('skips detection and returns false when the cache read fails', async () => { - mockCacheGet.mockRejectedValueOnce(new Error('redis down')); - - const result = await detectAnomaly(1.0, ASSET, null); - - expect(result).toBe(false); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Cache read failed in anomaly detection, skipping', - expect.objectContaining({ error: 'redis down' }) - ); - expect(mockCacheSet).not.toHaveBeenCalled(); - }); - - test('swallows a cache write failure without throwing', async () => { - mockCacheGet.mockResolvedValueOnce(null); - mockCacheSet.mockRejectedValueOnce(new Error('write failed')); - - await expect(detectAnomaly(1.0, ASSET, null)).resolves.toBe(false); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Cache write failed in anomaly detection', - expect.objectContaining({ error: 'write failed' }) - ); - }); -}); - -describe('fetchFromAllSources', () => { - const { fetchFromAllSources } = oracle; - - test('returns a result entry for every source that succeeds', async () => { - mockStellarFetch.mockResolvedValueOnce(0.1); - mockCoingeckoFetch.mockResolvedValueOnce(0.11); - mockCoinmarketcapFetch.mockResolvedValueOnce(0.12); - - const results = await fetchFromAllSources('XLM', null); - - expect(results).toEqual([ - { source: 'stellar_dex', price: 0.1 }, - { source: 'coingecko', price: 0.11 }, - { source: 'coinmarketcap', price: 0.12 }, - ]); - }); - - test('swallows a throwing source and returns the healthy ones', async () => { - mockStellarFetch.mockResolvedValueOnce(0.1); - mockCoingeckoFetch.mockRejectedValueOnce(new Error('timeout')); - mockCoinmarketcapFetch.mockResolvedValueOnce(0.12); - - const results = await fetchFromAllSources('XLM', null); - - expect(results).toEqual([ - { source: 'stellar_dex', price: 0.1 }, - { source: 'coinmarketcap', price: 0.12 }, - ]); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Source fetch failed', - expect.objectContaining({ source: 'coingecko', error: 'timeout' }) - ); - }); - - test('returns an empty array when every source throws', async () => { - mockStellarFetch.mockRejectedValueOnce(new Error('a')); - mockCoingeckoFetch.mockRejectedValueOnce(new Error('b')); - mockCoinmarketcapFetch.mockRejectedValueOnce(new Error('c')); - - const results = await fetchFromAllSources('XLM', null); - - expect(results).toEqual([]); - }); - - test('ignores null and non-positive prices from sources', async () => { - mockStellarFetch.mockResolvedValueOnce(null); - mockCoingeckoFetch.mockResolvedValueOnce(0); - mockCoinmarketcapFetch.mockResolvedValueOnce(0.12); - - const results = await fetchFromAllSources('XLM', null); - - expect(results).toEqual([{ source: 'coinmarketcap', price: 0.12 }]); - }); - - test('accepts a single healthy source (no minimum-quorum rule)', async () => { - mockStellarFetch.mockResolvedValueOnce(0.1); - mockCoingeckoFetch.mockResolvedValueOnce(null); - mockCoinmarketcapFetch.mockRejectedValueOnce(new Error('down')); - - const results = await fetchFromAllSources('XLM', null); - - expect(results).toEqual([{ source: 'stellar_dex', price: 0.1 }]); - }); -}); - -describe('getPrice', () => { - const { getPrice } = oracle; - - test('returns a fresh cached price with is_stale false on a cache hit', async () => { - const fetchedAt = Date.now() - 60 * 1000; // 1 minute old, threshold 5 - mockCacheGet.mockResolvedValueOnce({ - price: 1.01, - source: 'coingecko', - fetchedAt, - sourcesAttempted: ['stellar_dex', 'coingecko'], - }); - - const result = await getPrice('USDC', 'GISSUER'); - - expect(result).toMatchObject({ - asset_code: 'USDC', - issuer: 'GISSUER', - price_usd: 1.01, - source: 'coingecko', - is_stale: false, - stale_warning: null, - sources_attempted: ['stellar_dex', 'coingecko'], - redis_unavailable: false, - }); - // Cache hit must not fan out to the sources. - expect(mockStellarFetch).not.toHaveBeenCalled(); - }); - - test('flags is_stale and emits a warning when the cached entry is old', async () => { - const fetchedAt = Date.now() - 10 * 60 * 1000; // 10 minutes old, threshold 5 - mockCacheGet.mockResolvedValueOnce({ - price: 1.01, - source: 'coingecko', - fetchedAt, - sourcesAttempted: ['coingecko'], - }); - - const result = await getPrice('USDC', null); - - expect(result.is_stale).toBe(true); - expect(result.stale_warning).toMatch(/threshold: 5 min/); - }); - - test('defaults sources_attempted to an empty array when absent from the cache entry', async () => { - mockCacheGet.mockResolvedValueOnce({ - price: 1.01, - source: 'coingecko', - fetchedAt: Date.now(), - }); - - const result = await getPrice('USDC', null); - - expect(result.sources_attempted).toEqual([]); - }); - - test('falls through to a fresh fetch and caches the result on a cache miss', async () => { - mockCacheGet.mockResolvedValueOnce(null); // miss - mockStellarFetch.mockResolvedValueOnce(0.1); - mockCoingeckoFetch.mockResolvedValueOnce(0.12); - mockCoinmarketcapFetch.mockResolvedValueOnce(0.11); - - const result = await getPrice('XLM', null); - - // median([0.1, 0.12, 0.11]) === 0.11 - expect(result.price_usd).toBe(0.11); - expect(result.is_stale).toBe(false); - expect(result.sources_attempted).toEqual(['stellar_dex', 'coingecko', 'coinmarketcap']); - // Result is written back to the main cache key. - expect(mockCacheSet).toHaveBeenCalledWith( - 'price:XLM', - expect.objectContaining({ price: 0.11, source: 'stellar_dex' }), - 60 - ); - }); - - test('marks redis_unavailable and still fetches when the cache read throws', async () => { - mockCacheGet.mockRejectedValueOnce(new Error('redis down')); - mockStellarFetch.mockResolvedValueOnce(0.1); - mockCoingeckoFetch.mockResolvedValueOnce(0.1); - mockCoinmarketcapFetch.mockResolvedValueOnce(0.1); - - const result = await getPrice('XLM', null); - - expect(result.redis_unavailable).toBe(true); - expect(result.price_usd).toBe(0.1); - // When redis is unavailable we must not attempt to write back. - expect(mockCacheSet).not.toHaveBeenCalled(); - }); -}); - -describe('fetchFreshPrice', () => { - const { fetchFreshPrice } = oracle; - - test('returns the unavailable shape when no source has data', async () => { - mockStellarFetch.mockResolvedValueOnce(null); - mockCoingeckoFetch.mockRejectedValueOnce(new Error('down')); - mockCoinmarketcapFetch.mockResolvedValueOnce(null); - - const result = await fetchFreshPrice('XLM', null); - - expect(result).toMatchObject({ - price_usd: null, - source: 'unavailable', - is_stale: true, - stale_warning: 'No price data available from any source', - }); - expect(mockCacheSet).not.toHaveBeenCalled(); - }); - - test('runs anomaly detection against the aggregated price when redis is available', async () => { - // No price history yet -> detectAnomaly stores the aggregate and the main key. - mockCacheGet.mockResolvedValue(null); - mockStellarFetch.mockResolvedValueOnce(1.0); - mockCoingeckoFetch.mockResolvedValueOnce(1.0); - mockCoinmarketcapFetch.mockResolvedValueOnce(1.0); - - await fetchFreshPrice('USDC', null); - - expect(mockCacheSet).toHaveBeenCalledWith( - 'price:history:USDC', - expect.objectContaining({ price: 1.0 }), - 3600 - ); - }); - - test('skips anomaly detection and cache writes when redisUnavailable is true', async () => { - mockStellarFetch.mockResolvedValueOnce(1.0); - mockCoingeckoFetch.mockResolvedValueOnce(1.0); - mockCoinmarketcapFetch.mockResolvedValueOnce(1.0); - - const result = await fetchFreshPrice('USDC', null, true); - - expect(result.redis_unavailable).toBe(true); - expect(mockCacheSet).not.toHaveBeenCalled(); - expect(mockCacheGet).not.toHaveBeenCalled(); - }); - - test('degrades to redis_unavailable when the cache write fails', async () => { - mockCacheGet.mockResolvedValue(null); - mockCacheSet - .mockResolvedValueOnce(undefined) // detectAnomaly history write succeeds - .mockRejectedValueOnce(new Error('write failed')); // main cache write fails - mockStellarFetch.mockResolvedValueOnce(1.0); - mockCoingeckoFetch.mockResolvedValueOnce(1.0); - mockCoinmarketcapFetch.mockResolvedValueOnce(1.0); - - const result = await fetchFreshPrice('USDC', null); - - expect(result.price_usd).toBe(1.0); - expect(result.redis_unavailable).toBe(true); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Cache write failed, continuing without caching', - expect.objectContaining({ error: 'write failed' }) - ); - }); -}); - -describe('refreshAllCachedPrices', () => { - const { refreshAllCachedPrices } = oracle; - - test('skips the cycle when redis is not connected', async () => { - mockCacheIsConnected.mockReturnValue(false); - - const result = await refreshAllCachedPrices(); - - expect(result).toBeUndefined(); - expect(mockCacheGetClient).not.toHaveBeenCalled(); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Redis unavailable, skipping scheduled price refresh cycle' - ); - }); - - test('scans cached keys, refreshes prices, and skips history keys', async () => { - mockCacheIsConnected.mockReturnValue(true); - - const redis = { - scan: jest - .fn() - // single scan pass: returns cursor '0' to terminate, with one price key and one history key - .mockResolvedValueOnce(['0', ['price:XLM', 'price:history:XLM']]), - }; - mockCacheGetClient.mockReturnValue(redis); - - // The refresh re-fetches fresh prices for the matched (non-history) key. - mockStellarFetch.mockResolvedValue(0.1); - mockCoingeckoFetch.mockResolvedValue(0.1); - mockCoinmarketcapFetch.mockResolvedValue(0.1); - - const result = await refreshAllCachedPrices(); - - expect(redis.scan).toHaveBeenCalledWith('0', 'MATCH', 'price:*', 'COUNT', 100); - // Only the non-history key is refreshed. - expect(mockStellarFetch).toHaveBeenCalledWith('XLM', null); - expect(result).toEqual({ XLM: { price: 0.1, source: 'stellar_dex' } }); - }); - - test('aborts the cycle when the redis scan throws', async () => { - mockCacheIsConnected.mockReturnValue(true); - const redis = { scan: jest.fn().mockRejectedValueOnce(new Error('scan failed')) }; - mockCacheGetClient.mockReturnValue(redis); - - const result = await refreshAllCachedPrices(); - - expect(result).toBeUndefined(); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Redis scan failed during price refresh, aborting cycle', - expect.objectContaining({ error: 'scan failed' }) - ); - }); -}); diff --git a/test/priceOracleCircuit.test.js b/test/priceOracleCircuit.test.js deleted file mode 100644 index 221e7ec..0000000 --- a/test/priceOracleCircuit.test.js +++ /dev/null @@ -1,48 +0,0 @@ -'use strict'; - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -jest.mock('../src/services/cache', () => ({ - get: jest.fn(), - set: jest.fn(), - del: jest.fn(), - getClient: jest.fn(), - isConnected: jest.fn(), -})); - -const mockCoingeckoCircuitState = { source: 'coingecko', open: true, openUntil: '2026-01-01T00:15:00.000Z' }; -const mockCmcCircuitState = { source: 'coinmarketcap', open: false, openUntil: null }; - -jest.mock('../src/services/sources/stellarDex', () => ({ - fetchPrice: jest.fn(), - // Deliberately no getCircuitState — stellar_dex has no auth-failure mode. -})); -jest.mock('../src/services/sources/coingecko', () => ({ - fetchPrice: jest.fn(), - getCircuitState: jest.fn(() => mockCoingeckoCircuitState), -})); -jest.mock('../src/services/sources/coinmarketcap', () => ({ - fetchPrice: jest.fn(), - getCircuitState: jest.fn(() => mockCmcCircuitState), -})); - -const priceOracle = require('../src/services/priceOracle'); - -describe('priceOracle.getSourceCircuitStates', () => { - test('returns the circuit state for every source that has one', () => { - const states = priceOracle.getSourceCircuitStates(); - - expect(states).toEqual([mockCoingeckoCircuitState, mockCmcCircuitState]); - }); - - test('omits sources with no getCircuitState (e.g. stellar_dex)', () => { - const states = priceOracle.getSourceCircuitStates(); - - expect(states.find((s) => s.source === 'stellar_dex')).toBeUndefined(); - }); -}); diff --git a/test/priceWebSocket.test.js b/test/priceWebSocket.test.js deleted file mode 100644 index cfcf04f..0000000 --- a/test/priceWebSocket.test.js +++ /dev/null @@ -1,179 +0,0 @@ -'use strict'; - -const http = require('http'); -const WebSocket = require('ws'); - -// ── Mock dependencies so the test never needs Redis or real price sources ── - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -jest.mock('../src/services/cache', () => ({ - isConnected: jest.fn(() => false), - get: jest.fn(), - set: jest.fn(), - disconnect: jest.fn(), - getClient: jest.fn(), -})); - -// ── Helpers ──────────────────────────────────────────────────────────────── - -function waitForMessage(ws, matcher) { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('timeout waiting for WS message')), 3000); - ws.on('message', (raw) => { - const msg = JSON.parse(raw.toString()); - if (!matcher || matcher(msg)) { - clearTimeout(timer); - resolve(msg); - } - }); - }); -} - -function connect(port) { - return new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://localhost:${port}/ws`); - ws.once('open', () => resolve(ws)); - ws.once('error', reject); - }); -} - -function send(ws, payload) { - ws.send(JSON.stringify(payload)); -} - -// ── Tests ────────────────────────────────────────────────────────────────── - -describe('WebSocket price stream', () => { - let httpServer; - let subscriptionManager; - let port; - - beforeAll((done) => { - // Fresh module instances for each test suite run. - jest.resetModules(); - - const { PriceSubscriptionManager } = require('../src/ws/PriceSubscriptionManager'); - subscriptionManager = new PriceSubscriptionManager(); - - const { WebSocketServer } = require('ws'); - httpServer = http.createServer(); - const wss = new WebSocketServer({ server: httpServer, path: '/ws' }); - wss.on('connection', (ws, req) => subscriptionManager.add(ws)); - - httpServer.listen(0, () => { - port = httpServer.address().port; - done(); - }); - }); - - afterAll((done) => { - subscriptionManager.stopHeartbeat(); - // Terminate any lingering client sockets so httpServer.close() resolves. - for (const ws of subscriptionManager._clients.keys()) { - ws.terminate(); - } - setTimeout(() => httpServer.close(done), 100); - }, 10000); - - test('client receives subscribed confirmation after subscribe action', async () => { - const ws = await connect(port); - const msgPromise = waitForMessage(ws, (m) => m.type === 'subscribed'); - send(ws, { action: 'subscribe', assets: ['XLM', 'USDC'] }); - const msg = await msgPromise; - expect(msg.assets).toEqual(expect.arrayContaining(['XLM', 'USDC'])); - ws.close(); - }); - - test('subscribe caps assets at MAX_ASSETS_PER_CLIENT (5)', async () => { - const ws = await connect(port); - const msgPromise = waitForMessage(ws, (m) => m.type === 'subscribed'); - send(ws, { action: 'subscribe', assets: ['A', 'B', 'C', 'D', 'E', 'F', 'G'] }); - const msg = await msgPromise; - expect(msg.assets.length).toBeLessThanOrEqual(5); - ws.close(); - }); - - test('client receives price_update after price changes > 0.1%', async () => { - const ws = await connect(port); - - // Subscribe first - const subPromise = waitForMessage(ws, (m) => m.type === 'subscribed'); - send(ws, { action: 'subscribe', assets: ['XLM'] }); - await subPromise; - - // Seed a previous price, then push a >0.1% change - subscriptionManager._previousPrices.set('XLM', 0.112); - - const updatePromise = waitForMessage(ws, (m) => m.type === 'price_update'); - subscriptionManager.notifyPriceUpdates({ XLM: { price: 0.1145, source: 'stellar_dex' } }); - - const update = await updatePromise; - expect(update.asset).toBe('XLM'); - expect(update.price_usd).toBe(0.1145); - expect(update.previous_price_usd).toBe(0.112); - expect(Math.abs(update.change_pct)).toBeGreaterThan(0.1); - ws.close(); - }); - - test('no push when price change is within 0.1% threshold', async () => { - const ws = await connect(port); - - const subPromise = waitForMessage(ws, (m) => m.type === 'subscribed'); - send(ws, { action: 'subscribe', assets: ['USDC'] }); - await subPromise; - - subscriptionManager._previousPrices.set('USDC', 1.0000); - - let received = false; - ws.on('message', () => { received = true; }); - - // Change < 0.1% - subscriptionManager.notifyPriceUpdates({ USDC: { price: 1.00005, source: 'coingecko' } }); - - // Wait briefly to confirm nothing was sent - await new Promise((r) => setTimeout(r, 200)); - expect(received).toBe(false); - ws.close(); - }); - - test('unsubscribe removes asset from client subscription', async () => { - const ws = await connect(port); - - const subPromise = waitForMessage(ws, (m) => m.type === 'subscribed'); - send(ws, { action: 'subscribe', assets: ['XLM'] }); - await subPromise; - - const unsubPromise = waitForMessage(ws, (m) => m.type === 'unsubscribed'); - send(ws, { action: 'unsubscribe', assets: ['XLM'] }); - const msg = await unsubPromise; - expect(msg.assets).not.toContain('XLM'); - ws.close(); - }); - - test('invalid JSON returns error message', async () => { - const ws = await connect(port); - const errPromise = waitForMessage(ws, (m) => m.type === 'error'); - ws.send('not-json'); - const msg = await errPromise; - expect(msg.message).toMatch(/invalid json/i); - ws.close(); - }); - - test('connectionCount increments on connect and decrements on disconnect', async () => { - // Wait for any sockets from earlier tests to fully close. - await new Promise((r) => setTimeout(r, 200)); - const before = subscriptionManager.connectionCount; - const ws = await connect(port); - await new Promise((r) => setTimeout(r, 100)); - expect(subscriptionManager.connectionCount).toBe(before + 1); - ws.close(); - await new Promise((r) => setTimeout(r, 100)); - expect(subscriptionManager.connectionCount).toBe(before); - }); -}); diff --git a/test/prices.test.js b/test/prices.test.js deleted file mode 100644 index 5f9c92a..0000000 --- a/test/prices.test.js +++ /dev/null @@ -1,284 +0,0 @@ -'use strict'; - -// --- Mocks (must precede all imports) --- - -jest.mock('../src/services/cache', () => ({ - get: jest.fn(), - set: jest.fn(), - del: jest.fn(), - disconnect: jest.fn(), - isConnected: jest.fn(() => false), -})); - -const mockGetPrice = jest.fn(); -const mockFetchFreshPrice = jest.fn(); - -jest.mock('../src/services/priceOracle', () => ({ - getPrice: mockGetPrice, - fetchFreshPrice: mockFetchFreshPrice, - refreshAllCachedPrices: jest.fn(), -})); - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -jest.mock('../src/services/apiKeys', () => ({ - validateApiKey: jest.fn(), -})); - -// --- Imports --- - -process.env.ADMIN_API_KEY = 'b'.repeat(64); - -const express = require('express'); -const request = require('supertest'); -const pricesRouter = require('../src/routes/prices'); -const logger = require('../src/logger'); -const { errorHandler } = require('../src/middleware/errorHandler'); -const apiKeys = require('../src/services/apiKeys'); - -function buildApp() { - const app = express(); - app.use(express.json()); - app.use('/api/v1', pricesRouter); - app.use(errorHandler); - return app; -} - -function priceResponse(overrides = {}) { - return { - asset_code: 'USDC', - issuer: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - price_usd: 1.01, - source: 'coingecko', - fetched_at: '2026-06-25T00:00:00.000Z', - is_stale: false, - stale_warning: null, - sources_attempted: ['coingecko'], - redis_unavailable: false, - ...overrides, - }; -} - -describe('price routes', () => { - let app; - - beforeEach(() => { - app = buildApp(); - mockGetPrice.mockReset(); - mockFetchFreshPrice.mockReset(); - apiKeys.validateApiKey.mockReset(); - logger.error.mockClear(); - }); - - describe('GET /api/v1/prices/:asset_code', () => { - test('returns the full price response shape', async () => { - mockGetPrice.mockResolvedValueOnce(priceResponse()); - - const res = await request(app) - .get('/api/v1/prices/usdc') - .query({ issuer: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }); - - expect(res.status).toBe(200); - expect(mockGetPrice).toHaveBeenCalledWith( - 'USDC', - 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - ); - expect(res.body).toEqual({ - asset_code: 'USDC', - issuer: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - price_usd: 1.01, - source: 'coingecko', - fetched_at: '2026-06-25T00:00:00.000Z', - is_stale: false, - stale_warning: null, - sources_attempted: ['coingecko'], - redis_unavailable: false, - }); - }); - - test('preserves stale warnings from the oracle', async () => { - mockGetPrice.mockResolvedValueOnce( - priceResponse({ - is_stale: true, - stale_warning: 'Price is 45.0 minutes old (threshold: 30 min)', - }) - ); - - const res = await request(app).get('/api/v1/prices/USDC'); - - expect(res.status).toBe(200); - expect(res.body.is_stale).toBe(true); - expect(res.body.stale_warning).toBe('Price is 45.0 minutes old (threshold: 30 min)'); - }); - - test('handles native XLM without an issuer', async () => { - mockGetPrice.mockResolvedValueOnce( - priceResponse({ - asset_code: 'XLM', - issuer: null, - price_usd: 0.12, - source: 'stellar_dex', - }) - ); - - const res = await request(app).get('/api/v1/prices/xlm'); - - expect(res.status).toBe(200); - expect(mockGetPrice).toHaveBeenCalledWith('XLM', null); - expect(res.body.asset_code).toBe('XLM'); - expect(res.body.issuer).toBeNull(); - }); - - test('returns 404 with a structured error when no source has data', async () => { - mockGetPrice.mockResolvedValueOnce( - priceResponse({ - price_usd: null, - source: 'unavailable', - is_stale: true, - stale_warning: 'No price data available from any source', - }) - ); - - const res = await request(app).get('/api/v1/prices/UNKNOWN'); - - expect(res.status).toBe(404); - expect(res.body.error).toMatchObject({ - code: 'NOT_FOUND', - message: expect.stringContaining('UNKNOWN'), - }); - }); - - test('rejects invalid asset codes before oracle lookup', async () => { - const res = await request(app).get('/api/v1/prices/TOO-LONG-ASSET'); - - expect(res.status).toBe(400); - expect(res.body.error).toMatchObject({ - code: 'VALIDATION_ERROR', - message: 'Validation failed', - }); - expect(res.body.error.details.fields.asset_code).toEqual( - expect.arrayContaining(['Asset code must be alphanumeric']) - ); - expect(mockGetPrice).not.toHaveBeenCalled(); - }); - - test('rejects malformed issuers before oracle lookup', async () => { - const res = await request(app) - .get('/api/v1/prices/USDC') - .query({ issuer: 'not-a-stellar-address' }); - - expect(res.status).toBe(400); - expect(res.body.error).toMatchObject({ - code: 'VALIDATION_ERROR', - message: 'Validation failed', - }); - expect(res.body.error.details.fields.issuer).toEqual( - expect.arrayContaining(['Must be a valid Stellar public key']) - ); - expect(mockGetPrice).not.toHaveBeenCalled(); - }); - - test('hides stack traces on unhandled oracle errors', async () => { - mockGetPrice.mockRejectedValueOnce(new Error('redis exploded with stack details')); - - const res = await request(app).get('/api/v1/prices/XLM'); - - expect(res.status).toBe(500); - expect(res.body.error).toMatchObject({ code: 'INTERNAL_ERROR' }); - expect(JSON.stringify(res.body)).not.toContain('redis exploded'); - }); - - test('Redis unavailable — 200 with redis_unavailable: true (graceful degradation)', async () => { - mockGetPrice.mockResolvedValueOnce(priceResponse({ redis_unavailable: true })); - - const res = await request(app).get('/api/v1/prices/USDC'); - - expect(res.status).toBe(200); - expect(res.body.redis_unavailable).toBe(true); - expect(res.body.price_usd).not.toBeNull(); - }); - }); - - describe('GET /api/v1/prices/:asset_code/refresh', () => { - test('no Authorization header — 401', async () => { - const res = await request(app).get('/api/v1/prices/XLM/refresh'); - - expect(res.status).toBe(401); - expect(res.body.error).toMatchObject({ - code: 'UNAUTHORIZED', - message: 'Missing or invalid API key', - }); - }); - - test('invalid API key — 401', async () => { - apiKeys.validateApiKey.mockResolvedValue(null); - - const res = await request(app) - .get('/api/v1/prices/XLM/refresh') - .set('Authorization', 'Bearer bad-key'); - - expect(res.status).toBe(401); - expect(res.body.error).toMatchObject({ - code: 'UNAUTHORIZED', - message: 'Missing or invalid API key', - }); - }); - - test('valid API key — 200 with full response shape', async () => { - apiKeys.validateApiKey.mockResolvedValue({ scopes: [] }); - mockFetchFreshPrice.mockResolvedValueOnce(priceResponse({ asset_code: 'XLM' })); - - const res = await request(app) - .get('/api/v1/prices/XLM/refresh') - .set('Authorization', 'Bearer valid-key'); - - expect(res.status).toBe(200); - expect(res.body).toMatchObject({ - asset_code: 'XLM', - price_usd: expect.any(Number), - }); - }); - - test('valid API key + oracle throws — 500, no internal details leaked', async () => { - apiKeys.validateApiKey.mockResolvedValue({ scopes: [] }); - mockFetchFreshPrice.mockRejectedValueOnce(new Error('External source failed')); - - const res = await request(app) - .get('/api/v1/prices/XLM/refresh') - .set('Authorization', 'Bearer valid-key'); - - expect(res.status).toBe(500); - expect(res.body.error).toMatchObject({ code: 'INTERNAL_ERROR' }); - expect(res.body).not.toHaveProperty('stack'); - expect(JSON.stringify(res.body)).not.toContain('External source failed'); - }); - - test('validates params and calls fresh oracle lookup', async () => { - apiKeys.validateApiKey.mockResolvedValue({ scopes: [] }); - mockFetchFreshPrice.mockResolvedValueOnce( - priceResponse({ - asset_code: 'USDC', - source: 'stellar_dex', - sources_attempted: ['stellar_dex'], - }) - ); - - const res = await request(app) - .get('/api/v1/prices/usdc/refresh') - .set('Authorization', `Bearer ${process.env.ADMIN_API_KEY}`) - .query({ issuer: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }); - - expect(res.status).toBe(200); - expect(mockFetchFreshPrice).toHaveBeenCalledWith( - 'USDC', - 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - ); - expect(res.body.source).toBe('stellar_dex'); - }); - }); -}); diff --git a/test/rateLimit.test.js b/test/rateLimit.test.js deleted file mode 100644 index 292b5e8..0000000 --- a/test/rateLimit.test.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -const express = require('express'); -const request = require('supertest'); -const { createCacheMock } = require('./helpers/cacheMock'); - -const mockHelper = createCacheMock(); -const { reset } = mockHelper; - -jest.mock('../src/services/cache', () => mockHelper.cacheMock); -jest.mock('../src/logger', () => ({ - info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), -})); - -const buildRateLimit = require('../src/middleware/rateLimit'); -const { errorHandler } = require('../src/middleware/errorHandler'); - -function buildApp(limiter) { - const app = express(); - app.use(limiter); - app.get('/test', (_req, res) => res.json({ ok: true })); - app.use(errorHandler); - return app; -} - -beforeEach(() => reset()); - -describe('rateLimit middleware', () => { - test('allows requests under the limit and sets rate-limit headers', async () => { - const app = buildApp(buildRateLimit({ windowSeconds: 60, max: 3, keyPrefix: 't' })); - const r1 = await request(app).get('/test'); - expect(r1.status).toBe(200); - expect(r1.headers['x-ratelimit-limit']).toBe('3'); - expect(r1.headers['x-ratelimit-remaining']).toBe('2'); - }); - - test('returns 429 once the limit is exceeded', async () => { - const app = buildApp(buildRateLimit({ windowSeconds: 60, max: 2, keyPrefix: 'lim' })); - await request(app).get('/test'); - await request(app).get('/test'); - const blocked = await request(app).get('/test'); - expect(blocked.status).toBe(429); - expect(blocked.body.error).toMatchObject({ code: 'RATE_LIMITED' }); - expect(blocked.body.error.details.retry_after_seconds).toBeGreaterThan(0); - expect(blocked.headers['retry-after']).toBeDefined(); - }); - - test('throws when configured with invalid options', () => { - expect(() => buildRateLimit({ windowSeconds: 0, max: 10, keyPrefix: 'x' })).toThrow(); - expect(() => buildRateLimit({ windowSeconds: 60, max: 0, keyPrefix: 'x' })).toThrow(); - expect(() => buildRateLimit({ windowSeconds: 60, max: 10 })).toThrow(); - }); -}); diff --git a/test/requestId.test.js b/test/requestId.test.js deleted file mode 100644 index 4651b7b..0000000 --- a/test/requestId.test.js +++ /dev/null @@ -1,122 +0,0 @@ -'use strict'; - -const express = require('express'); -const request = require('supertest'); -const { requestIdMiddleware, requestContext } = require('../src/middleware/requestId'); - -function buildTestApp(onRequest) { - const app = express(); - app.use(requestIdMiddleware); - app.get('/test', (req, res) => { - onRequest(req); - res.json({ ok: true }); - }); - return app; -} - -describe('requestId middleware', () => { - test('sets X-Request-ID response header on every response', async () => { - const app = buildTestApp(() => {}); - - const res = await request(app).get('/test'); - - expect(res.status).toBe(200); - expect(res.headers['x-request-id']).toBeDefined(); - expect(res.headers['x-request-id']).toMatch(/^req_[0-9a-zA-Z_-]+$/); - }); - - test('attaches the same ID to req.id and the response header', async () => { - let capturedReqId; - const app = buildTestApp((req) => { - capturedReqId = req.id; - }); - - const res = await request(app).get('/test'); - - expect(capturedReqId).toBe(res.headers['x-request-id']); - }); - - test('runs downstream handlers inside AsyncLocalStorage context', async () => { - let storeRequestId; - const app = buildTestApp((req) => { - storeRequestId = requestContext.getStore()?.requestId; - expect(storeRequestId).toBe(req.id); - }); - - const res = await request(app).get('/test'); - - expect(storeRequestId).toBe(res.headers['x-request-id']); - }); -}); - -describe('logger requestId correlation', () => { - let writeSpy; - - beforeEach(() => { - jest.resetModules(); - process.env.LOG_FORMAT = 'json'; - process.env.LOG_LEVEL = 'info'; - // Winston's Console transport prefers `console._stdout` over - // `process.stdout` directly (see winston/lib/winston/transports/console.js). - // Depending on test run order, Jest's per-file console can end up wrapping - // a different stream object than `process.stdout`, so spy on whichever one - // Winston will actually call. - const target = console._stdout || process.stdout; - writeSpy = jest.spyOn(target, 'write').mockImplementation((chunk, _encoding, cb) => { - if (typeof cb === 'function') cb(); - return true; - }); - }); - - afterEach(() => { - writeSpy.mockRestore(); - }); - - function findLogLine(message) { - return writeSpy.mock.calls - .map(([chunk]) => chunk.toString()) - .find((line) => line.includes(message)); - } - - test('log output includes matching requestId for a given request', async () => { - const { requestIdMiddleware: middleware } = require('../src/middleware/requestId'); - const logger = require('../src/logger'); - - const app = express(); - app.use(middleware); - app.get('/test', (req, res) => { - logger.info('Handling correlated request'); - res.json({ ok: true }); - }); - - const res = await request(app).get('/test'); - const logLine = findLogLine('Handling correlated request'); - - expect(logLine).toBeDefined(); - const parsed = JSON.parse(logLine); - expect(parsed.requestId).toBe(res.headers['x-request-id']); - }); - - test('background tasks log with requestId system', () => { - const logger = require('../src/logger'); - - logger.info('Background task running'); - - const logLine = findLogLine('Background task running'); - expect(logLine).toBeDefined(); - const parsed = JSON.parse(logLine); - expect(parsed.requestId).toBe('system'); - }); -}); - -describe('requestId on app routes', () => { - test('health endpoint returns X-Request-ID header', async () => { - jest.resetModules(); - const { app } = require('../src/index'); - - const res = await request(app).get('/health'); - - expect(res.status).toBe(200); - expect(res.headers['x-request-id']).toBeDefined(); - }); -}); \ No newline at end of file diff --git a/test/resilience.test.js b/test/resilience.test.js deleted file mode 100644 index d1cb442..0000000 --- a/test/resilience.test.js +++ /dev/null @@ -1,206 +0,0 @@ -'use strict'; - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -const mockCacheGet = jest.fn(); -const mockCacheSet = jest.fn(); -const mockIsConnected = jest.fn(); - -jest.mock('../src/services/cache', () => ({ - get: mockCacheGet, - set: mockCacheSet, - del: jest.fn(), - getClient: jest.fn(() => ({ scan: jest.fn(async () => ['0', []]) })), - isConnected: mockIsConnected, -})); - -const mockStellarFetch = jest.fn(); -const mockCoingeckoFetch = jest.fn(); -const mockCmcFetch = jest.fn(); - -jest.mock('../src/services/sources/stellarDex', () => ({ fetchPrice: mockStellarFetch })); -jest.mock('../src/services/sources/coingecko', () => ({ fetchPrice: mockCoingeckoFetch })); -jest.mock('../src/services/sources/coinmarketcap', () => ({ fetchPrice: mockCmcFetch })); - -const logger = require('../src/logger'); -const priceOracle = require('../src/services/priceOracle'); - -beforeEach(() => { - mockCacheGet.mockReset(); - mockCacheSet.mockReset(); - mockIsConnected.mockReset(); - mockStellarFetch.mockReset(); - mockCoingeckoFetch.mockReset(); - mockCmcFetch.mockReset(); - priceOracle.resetCircuitBreakers(); - logger.info.mockClear(); - logger.warn.mockClear(); - logger.error.mockClear(); - - // Default: sources return a price - mockStellarFetch.mockResolvedValue(0.10); - mockCoingeckoFetch.mockResolvedValue(null); - mockCmcFetch.mockResolvedValue(null); -}); - -describe('cache.get failure — falls back to source fetch', () => { - test('returns price data when cache.get throws', async () => { - mockCacheGet.mockRejectedValue(new Error('ECONNREFUSED')); - mockCacheSet.mockResolvedValue(undefined); - - const result = await priceOracle.getPrice('XLM'); - - expect(result.price_usd).toBe(0.10); - expect(result.redis_unavailable).toBe(true); - }); - - test('sets redis_unavailable: true on cache.get error', async () => { - mockCacheGet.mockRejectedValue(new Error('ECONNREFUSED')); - - const result = await priceOracle.getPrice('XLM'); - - expect(result.redis_unavailable).toBe(true); - }); - - test('logs a warning (not an error) on cache.get failure', async () => { - mockCacheGet.mockRejectedValue(new Error('Stream not writeable')); - - await priceOracle.getPrice('XLM'); - - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('Cache read failed'), - expect.objectContaining({ error: 'Stream not writeable' }) - ); - expect(logger.error).not.toHaveBeenCalled(); - }); - - test('does not throw — no unhandled rejection', async () => { - mockCacheGet.mockRejectedValue(new Error('ECONNREFUSED')); - - await expect(priceOracle.getPrice('XLM')).resolves.toBeDefined(); - }); -}); - -describe('cache.set failure — logs warning, returns price anyway', () => { - test('returns price data when cache.set throws', async () => { - mockCacheGet.mockResolvedValue(null); - mockCacheSet.mockRejectedValue(new Error('ECONNREFUSED')); - - const result = await priceOracle.getPrice('XLM'); - - expect(result.price_usd).toBe(0.10); - expect(result.redis_unavailable).toBe(true); - }); - - test('logs a warning on cache.set failure', async () => { - mockCacheGet.mockResolvedValue(null); - mockCacheSet.mockRejectedValue(new Error('offline queue full')); - - await priceOracle.getPrice('XLM'); - - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('Cache write failed'), - expect.objectContaining({ error: 'offline queue full' }) - ); - }); - - test('does not throw when cache.set fails', async () => { - mockCacheGet.mockResolvedValue(null); - mockCacheSet.mockRejectedValue(new Error('ECONNREFUSED')); - - await expect(priceOracle.getPrice('XLM')).resolves.toBeDefined(); - }); -}); - -describe('cache working normally', () => { - test('returns cached price with redis_unavailable: false', async () => { - mockCacheGet.mockResolvedValue({ - price: 0.12, - source: 'stellar_dex', - fetchedAt: Date.now() - 30000, - sourcesAttempted: ['stellar_dex'], - }); - - const result = await priceOracle.getPrice('XLM'); - - expect(result.price_usd).toBe(0.12); - expect(result.redis_unavailable).toBe(false); - expect(mockStellarFetch).not.toHaveBeenCalled(); - }); - - test('fetchFreshPrice sets redis_unavailable: false when cache.set succeeds', async () => { - mockCacheGet.mockResolvedValue(null); - mockCacheSet.mockResolvedValue(undefined); - - const result = await priceOracle.fetchFreshPrice('XLM'); - - expect(result.redis_unavailable).toBe(false); - }); -}); - -describe('all sources unavailable during Redis outage', () => { - test('returns null price with redis_unavailable: true', async () => { - mockCacheGet.mockRejectedValue(new Error('ECONNREFUSED')); - mockStellarFetch.mockResolvedValue(null); - mockCoingeckoFetch.mockResolvedValue(null); - mockCmcFetch.mockResolvedValue(null); - - const result = await priceOracle.getPrice('XLM'); - - expect(result.price_usd).toBeNull(); - expect(result.redis_unavailable).toBe(true); - expect(result.is_stale).toBe(true); - }); -}); - -describe('price source circuit breakers', () => { - test('opens after repeated source failures and skips the failing source', async () => { - mockCacheGet.mockResolvedValue(null); - mockCacheSet.mockResolvedValue(undefined); - mockStellarFetch.mockResolvedValue(null); - mockCoingeckoFetch.mockResolvedValue(null); - mockCmcFetch.mockResolvedValue(null); - - await priceOracle.fetchFreshPrice('XLM'); - await priceOracle.fetchFreshPrice('XLM'); - await priceOracle.fetchFreshPrice('XLM'); - - expect(priceOracle.getCircuitStates()).toMatchObject({ - stellar_dex: 'open', - coingecko: 'open', - coinmarketcap: 'open', - }); - - mockStellarFetch.mockClear(); - await priceOracle.fetchFreshPrice('XLM'); - - expect(mockStellarFetch).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith( - 'Circuit breaker open, skipping source call', - expect.objectContaining({ source: 'stellar_dex', state: 'open' }) - ); - }); -}); - -describe('refreshAllCachedPrices when Redis is down', () => { - test('skips refresh cycle when isConnected returns false', async () => { - mockIsConnected.mockReturnValue(false); - - await priceOracle.refreshAllCachedPrices(); - - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Redis unavailable')); - expect(mockStellarFetch).not.toHaveBeenCalled(); - }); -}); - -describe('cache.isConnected', () => { - test('cache module exports isConnected function', () => { - const cache = require('../src/services/cache'); - expect(typeof cache.isConnected).toBe('function'); - }); -}); diff --git a/test/stellarDex.test.js b/test/stellarDex.test.js deleted file mode 100644 index 597e97d..0000000 --- a/test/stellarDex.test.js +++ /dev/null @@ -1,176 +0,0 @@ -'use strict'; - -const mockOrderbook = jest.fn(); -const mockServer = { orderbook: mockOrderbook }; -const mockServerConstructor = jest.fn(() => mockServer); -const mockNativeAsset = { native: true }; -const mockAsset = jest.fn(function Asset(code, issuer) { - return { code, issuer }; -}); -mockAsset.native = jest.fn(() => mockNativeAsset); - -jest.mock('stellar-sdk', () => ({ - Horizon: { - Server: mockServerConstructor, - }, - Asset: mockAsset, -})); - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -const config = require('../src/config'); -const logger = require('../src/logger'); -const stellarDex = require('../src/services/sources/stellarDex'); - -const ISSUER = 'G'.padEnd(56, 'A'); - -function queueOrderBook(result) { - const call = jest.fn(); - if (result instanceof Error) { - call.mockRejectedValue(result); - } else { - call.mockResolvedValue(result); - } - - const limit = jest.fn(() => ({ call })); - mockOrderbook.mockImplementationOnce(() => ({ limit })); - return { call, limit }; -} - -beforeEach(() => { - mockOrderbook.mockReset(); - mockServerConstructor.mockClear(); - mockAsset.mockClear(); - mockAsset.native.mockClear(); - logger.warn.mockClear(); - logger.debug.mockClear(); -}); - -describe('Stellar DEX source', () => { - test('returns midpoint of best ask and best bid for issued assets', async () => { - queueOrderBook({ - bids: [{ price: '2.0' }], - asks: [{ price: '2.2' }], - }); - queueOrderBook({ - bids: [{ price: '0.10' }], - asks: [{ price: '0.12' }], - }); - - await expect(stellarDex.fetchPrice('TEST', ISSUER)).resolves.toBeCloseTo(0.231); - - expect(mockOrderbook).toHaveBeenNthCalledWith( - 1, - { code: 'TEST', issuer: ISSUER }, - mockNativeAsset - ); - expect(mockOrderbook).toHaveBeenNthCalledWith( - 2, - mockNativeAsset, - { code: 'USDC', issuer: config.stellar.usdcIssuer } - ); - }); - - test('uses best bid when asks are empty', async () => { - queueOrderBook({ - bids: [{ price: '0.11' }], - asks: [], - }); - - await expect(stellarDex.fetchPrice('XLM')).resolves.toBe(0.11); - }); - - test('uses best ask when bids are empty', async () => { - queueOrderBook({ - bids: [], - asks: [{ price: '0.12' }], - }); - - await expect(stellarDex.fetchPrice('XLM')).resolves.toBe(0.12); - }); - - test('returns null when asks and bids are empty', async () => { - queueOrderBook({ - bids: [], - asks: [], - }); - - await expect(stellarDex.fetchPrice('XLM')).resolves.toBeNull(); - }); - - test('returns null for issued assets when issuer is missing', async () => { - await expect(stellarDex.fetchPrice('USDC')).resolves.toBeNull(); - - expect(mockOrderbook).not.toHaveBeenCalled(); - expect(logger.debug).toHaveBeenCalledWith( - 'Stellar DEX issuer required for issued asset', - { assetCode: 'USDC' } - ); - }); - - test('throws when Horizon returns a non-200 error', async () => { - const error = new Error('Horizon request failed with status 500'); - error.response = { status: 500 }; - queueOrderBook(error); - - await expect(stellarDex.fetchPrice('XLM')).rejects.toThrow('status 500'); - expect(logger.warn).toHaveBeenCalledWith( - 'Stellar DEX price fetch failed', - expect.objectContaining({ assetCode: 'XLM', error: error.message }) - ); - }); - - test('throws timeout errors from Horizon', async () => { - const error = new Error('timeout of 10000ms exceeded'); - error.code = 'ECONNABORTED'; - queueOrderBook(error); - - await expect(stellarDex.fetchPrice('XLM')).rejects.toThrow('timeout'); - expect(logger.warn).toHaveBeenCalledWith( - 'Stellar DEX price fetch failed', - expect.objectContaining({ assetCode: 'XLM', error: error.message }) - ); - }); - - test('throws and logs when XLM/USD conversion lookup fails', async () => { - queueOrderBook({ - bids: [{ price: '2.0' }], - asks: [{ price: '2.2' }], - }); - const error = new Error('XLM/USDC lookup failed'); - queueOrderBook(error); - - await expect(stellarDex.fetchPrice('TEST', ISSUER)).rejects.toThrow( - 'XLM/USDC lookup failed' - ); - expect(logger.warn).toHaveBeenCalledWith('XLM/USDC price fetch failed', { - error: error.message, - }); - expect(logger.warn).toHaveBeenCalledWith( - 'Stellar DEX price fetch failed', - expect.objectContaining({ assetCode: 'TEST', issuer: ISSUER }) - ); - }); - - test('uses the native XLM/USDC orderbook for XLM without issuer', async () => { - queueOrderBook({ - bids: [{ price: '0.11' }], - asks: [{ price: '0.12' }], - }); - - await expect(stellarDex.fetchPrice('XLM')).resolves.toBeCloseTo(0.115); - - expect(mockOrderbook).toHaveBeenCalledTimes(1); - expect(mockAsset.native).toHaveBeenCalledTimes(1); - expect(mockAsset).toHaveBeenCalledWith('USDC', config.stellar.usdcIssuer); - expect(mockOrderbook).toHaveBeenCalledWith( - mockNativeAsset, - { code: 'USDC', issuer: config.stellar.usdcIssuer } - ); - }); -}); diff --git a/test/validate.test.js b/test/validate.test.js deleted file mode 100644 index eae4354..0000000 --- a/test/validate.test.js +++ /dev/null @@ -1,57 +0,0 @@ -'use strict'; - -const express = require('express'); -const request = require('supertest'); -const { z } = require('zod'); - -const { validate } = require('../src/middleware/validate'); -const { errorHandler } = require('../src/middleware/errorHandler'); - -function buildApp(schema, source = 'body') { - const app = express(); - app.use(express.json()); - app.post('/validate/:id?', validate(schema, source), (req, res) => { - res.json({ validated: req.validated[source] }); - }); - app.use(errorHandler); - return app; -} - -describe('validate middleware', () => { - test('stores parsed body data on req.validated', async () => { - const app = buildApp(z.object({ - count: z.coerce.number().int().min(1), - })); - - const res = await request(app).post('/validate').send({ count: '3' }); - - expect(res.status).toBe(200); - expect(res.body.validated).toEqual({ count: 3 }); - }); - - test('returns a validation AppError with flattened field details', async () => { - const app = buildApp(z.object({ - count: z.coerce.number().int().min(1), - })); - - const res = await request(app).post('/validate').send({ count: 0 }); - - expect(res.status).toBe(400); - expect(res.body.error).toMatchObject({ - code: 'VALIDATION_ERROR', - message: 'Validation failed', - }); - expect(res.body.error.details.fields.count).toEqual(expect.any(Array)); - }); - - test('validates route params before the handler runs', async () => { - const app = buildApp(z.object({ - id: z.string().regex(/^ok_[a-z]+$/), - }), 'params'); - - const res = await request(app).post('/validate/bad-id').send({}); - - expect(res.status).toBe(400); - expect(res.body.error.details.fields.id).toEqual(expect.any(Array)); - }); -}); diff --git a/test/webhookDispatcher.test.js b/test/webhookDispatcher.test.js deleted file mode 100644 index 645ffb2..0000000 --- a/test/webhookDispatcher.test.js +++ /dev/null @@ -1,333 +0,0 @@ -'use strict'; - -const { createCacheMock } = require('./helpers/cacheMock'); - -const mockHelper = createCacheMock(); -const { reset, zsets } = mockHelper; - -jest.mock('../src/services/cache', () => mockHelper.cacheMock); -jest.mock('../src/logger', () => ({ - info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), -})); - -const mockAxiosPost = jest.fn(); -jest.mock('axios', () => ({ post: (...args) => mockAxiosPost(...args) })); - -const dispatcher = require('../src/services/webhookDispatcher'); -const webhookRepo = require('../src/repositories/webhookRepository'); -const deliveryRepo = require('../src/repositories/deliveryRepository'); -const signature = require('../src/services/webhookSignature'); - -beforeEach(() => { - reset(); - mockAxiosPost.mockReset(); -}); - -async function createWebhook(overrides = {}) { - return webhookRepo.create({ - url: 'https://example.com/hook', - events: ['pool.assets_locked'], - secret: 'whsec_aaaaaaaaaaaaaaaa', - ...overrides, - }); -} - -describe('dispatcher delivery success', () => { - test('successful 200 marks delivery as success with attempts=1', async () => { - const w = await createWebhook(); - mockAxiosPost.mockResolvedValueOnce({ status: 200 }); - - const [delivery] = await dispatcher.dispatch({ - event_type: 'pool.assets_locked', - event_id: 'evt_1', - data: { pool_id: 'p1' }, - }); - - expect(delivery.status).toBe('success'); - expect(delivery.attempts).toBe(1); - expect(delivery.response_status).toBe(200); - expect(mockAxiosPost).toHaveBeenCalledTimes(1); - - const [url, body, opts] = mockAxiosPost.mock.calls[0]; - expect(url).toBe(w.url); - expect(typeof body).toBe('string'); - const parsed = JSON.parse(body); - expect(parsed.event).toBe('pool.assets_locked'); - expect(parsed.event_id).toBe('evt_1'); - expect(parsed.data).toEqual({ pool_id: 'p1' }); - expect(opts.headers['X-SmartDrop-Signature']).toBe(signature.sign(w.secret, body)); - expect(opts.headers['X-SmartDrop-Event']).toBe('pool.assets_locked'); - }); -}); - -describe('dispatcher event-type filtering', () => { - test('only webhooks subscribed to the event receive a delivery', async () => { - await createWebhook({ url: 'https://a.com', events: ['pool.assets_locked'] }); - await createWebhook({ url: 'https://b.com', events: ['pool.closed'] }); - await createWebhook({ url: 'https://c.com', events: ['*'] }); - mockAxiosPost.mockResolvedValue({ status: 200 }); - - const results = await dispatcher.dispatch({ - event_type: 'pool.assets_locked', - event_id: 'evt_42', - }); - - expect(results).toHaveLength(2); - const urls = mockAxiosPost.mock.calls.map((c) => c[0]).sort(); - expect(urls).toEqual(['https://a.com', 'https://c.com']); - }); - - test('inactive webhooks are skipped', async () => { - const w = await createWebhook(); - await webhookRepo.update(w.id, { active: false }); - mockAxiosPost.mockResolvedValue({ status: 200 }); - - const results = await dispatcher.dispatch({ - event_type: 'pool.assets_locked', - event_id: 'evt_skip', - }); - expect(results).toHaveLength(0); - expect(mockAxiosPost).not.toHaveBeenCalled(); - }); - - test('unknown event types do not dispatch', async () => { - await createWebhook(); - const results = await dispatcher.dispatch({ - event_type: 'foo.bar', - event_id: 'evt_x', - }); - expect(results).toEqual([]); - expect(mockAxiosPost).not.toHaveBeenCalled(); - }); -}); - -describe('dispatcher retry semantics', () => { - test('5xx schedules a retry and keeps status pending', async () => { - await createWebhook(); - mockAxiosPost.mockResolvedValueOnce({ status: 503 }); - - const [delivery] = await dispatcher.dispatch({ - event_type: 'pool.assets_locked', - event_id: 'evt_retry', - }); - - expect(delivery.status).toBe('pending'); - expect(delivery.attempts).toBe(1); - expect(delivery.next_retry_at).not.toBeNull(); - expect(delivery.last_error).toBe('HTTP 503'); - const queued = zsets.get('webhooks:retries'); - expect(queued.size).toBe(1); - expect([...queued.keys()][0]).toBe(delivery.id); - }); - - test('4xx (non-429) does NOT retry and marks failed', async () => { - await createWebhook(); - mockAxiosPost.mockResolvedValueOnce({ status: 400 }); - - const [delivery] = await dispatcher.dispatch({ - event_type: 'pool.assets_locked', - event_id: 'evt_4xx', - }); - - expect(delivery.status).toBe('failed'); - expect(delivery.attempts).toBe(1); - expect(delivery.next_retry_at).toBeNull(); - const queued = zsets.get('webhooks:retries') || new Map(); - expect(queued.size).toBe(0); - }); - - test('network errors trigger a retry', async () => { - await createWebhook(); - mockAxiosPost.mockRejectedValueOnce(new Error('ECONNREFUSED')); - - const [delivery] = await dispatcher.dispatch({ - event_type: 'pool.assets_locked', - event_id: 'evt_net', - }); - - expect(delivery.status).toBe('pending'); - expect(delivery.last_error).toBe('ECONNREFUSED'); - expect(delivery.next_retry_at).not.toBeNull(); - }); - - test('after maxAttempts failures the delivery is permanently failed', async () => { - await createWebhook(); - const [delivery] = await dispatcher.dispatch({ - event_type: 'pool.assets_locked', - event_id: 'evt_max', - }); - - mockAxiosPost.mockResolvedValue({ status: 500 }); - const second = await dispatcher.attempt(delivery.id); - const third = await dispatcher.attempt(delivery.id); - - expect(third.status).toBe('failed'); - expect(third.attempts).toBe(3); - expect(third.next_retry_at).toBeNull(); - expect(second.status).toBe('pending'); - }); - - test('429 is treated as retryable', async () => { - await createWebhook(); - mockAxiosPost.mockResolvedValueOnce({ status: 429 }); - - const [delivery] = await dispatcher.dispatch({ - event_type: 'pool.assets_locked', - event_id: 'evt_429', - }); - expect(delivery.status).toBe('pending'); - expect(delivery.next_retry_at).not.toBeNull(); - }); -}); - -describe('exponential backoff', () => { - test('delay grows by retryFactor each attempt', () => { - const d1 = dispatcher.backoffMs(1); - const d2 = dispatcher.backoffMs(2); - const d3 = dispatcher.backoffMs(3); - expect(d2).toBeGreaterThan(d1); - expect(d3).toBeGreaterThan(d2); - }); -}); - -describe('backoff jitter (#128)', () => { - test('repeated calls with the same attemptsCompleted produce a distribution, not an identical value', () => { - // Simulates 100 deliveries all failing on attempt 1 "at once" — before - // jitter, every one of these computed exactly the same delay. - const delays = new Set(); - for (let i = 0; i < 100; i++) { - delays.add(dispatcher.backoffMs(1)); - } - expect(delays.size).toBeGreaterThan(1); - }); - - test('an injected random source of 0 produces exactly the lower bound (deterministic / 2)', () => { - // config defaults: base=30000, factor=2 -> attempt 1 deterministic=30000 - const delay = dispatcher.backoffMs(1, { random: () => 0 }); - expect(delay).toBe(15000); - }); - - test('an injected random source just under 1 stays just under the deterministic upper bound', () => { - const delay = dispatcher.backoffMs(1, { random: () => 0.999999 }); - expect(delay).toBeLessThan(30000); - expect(delay).toBeGreaterThan(29999); - }); - - test('delay is never zero or negative, even at the minimum jitter, across several attempt counts', () => { - for (let attempt = 1; attempt <= 5; attempt++) { - const delay = dispatcher.backoffMs(attempt, { random: () => 0 }); - expect(delay).toBeGreaterThan(0); - } - }); - - test('delay never reaches or exceeds the undjittered deterministic value, across several attempt counts', () => { - const base = 30000; - const factor = 2; - for (let attempt = 1; attempt <= 5; attempt++) { - const deterministic = base * factor ** (attempt - 1); - const delay = dispatcher.backoffMs(attempt, { random: () => 0.999999 }); - expect(delay).toBeLessThan(deterministic); - } - }); - - test('delay still strictly grows across attempts even in the worst-case jitter ordering', () => { - // Worst case for monotonicity: attempt N rolls the minimum possible - // jitter (random=0) while attempt N-1 rolls the maximum (random~1). - // Even then, attempt N's delay must exceed attempt N-1's, because the - // default 2x factor means each attempt's [half, full) range never - // overlaps the previous attempt's range. - const attempt1Max = dispatcher.backoffMs(1, { random: () => 0.999999 }); - const attempt2Min = dispatcher.backoffMs(2, { random: () => 0 }); - expect(attempt2Min).toBeGreaterThan(attempt1Max); - - const attempt2Max = dispatcher.backoffMs(2, { random: () => 0.999999 }); - const attempt3Min = dispatcher.backoffMs(3, { random: () => 0 }); - expect(attempt3Min).toBeGreaterThan(attempt2Max); - }); - - test('defaults to the real Math.random when no random source is injected', () => { - const spy = jest.spyOn(Math, 'random').mockReturnValue(0.5); - try { - const delay = dispatcher.backoffMs(1); - expect(spy).toHaveBeenCalled(); - expect(delay).toBe(15000 + 0.5 * 15000); - } finally { - spy.mockRestore(); - } - }); -}); - -describe('thundering-herd prevention across a real dispatch tick (#128)', () => { - test('many deliveries failing at the same attempt within one tick get spread next_retry_at values', async () => { - // 20 different subscribers, all failing on attempt 1 at the same - // wall-clock moment (one dispatch() call, one Promise.all batch) — - // exactly the scenario the issue describes: a correlated outage - // affecting many in-flight deliveries at once. - const webhookCount = 20; - for (let i = 0; i < webhookCount; i++) { - await createWebhook({ url: `https://sub-${i}.example.com` }); - } - mockAxiosPost.mockResolvedValue({ status: 503 }); - - const deliveries = await dispatcher.dispatch({ - event_type: 'pool.assets_locked', - event_id: 'evt_herd', - }); - - expect(deliveries).toHaveLength(webhookCount); - deliveries.forEach((d) => expect(d.status).toBe('pending')); - - const nextRetryAtValues = new Set(deliveries.map((d) => d.next_retry_at)); - expect(nextRetryAtValues.size).toBeGreaterThan(1); - }); -}); - -describe('shouldRetry decision table', () => { - test('retries on network error', () => expect(dispatcher.shouldRetry(null, true)).toBe(true)); - test('retries on 500', () => expect(dispatcher.shouldRetry(500, false)).toBe(true)); - test('retries on 503', () => expect(dispatcher.shouldRetry(503, false)).toBe(true)); - test('retries on 408', () => expect(dispatcher.shouldRetry(408, false)).toBe(true)); - test('retries on 429', () => expect(dispatcher.shouldRetry(429, false)).toBe(true)); - test('does not retry on 400', () => expect(dispatcher.shouldRetry(400, false)).toBe(false)); - test('does not retry on 404', () => expect(dispatcher.shouldRetry(404, false)).toBe(false)); - test('does not retry on 200', () => expect(dispatcher.shouldRetry(200, false)).toBe(false)); -}); - -describe('sendTest', () => { - test('sends a test event to a specific webhook', async () => { - const w = await createWebhook(); - mockAxiosPost.mockResolvedValueOnce({ status: 200 }); - const delivery = await dispatcher.sendTest(w.id); - expect(delivery.status).toBe('success'); - expect(mockAxiosPost).toHaveBeenCalledTimes(1); - const body = JSON.parse(mockAxiosPost.mock.calls[0][1]); - expect(body.data.test).toBe(true); - }); - - test('returns null for unknown webhook', async () => { - const result = await dispatcher.sendTest('wh_unknown'); - expect(result).toBeNull(); - }); -}); - -describe('delivery payload persistence', () => { - test('payload is persisted so retries do not lose event data', async () => { - await createWebhook(); - mockAxiosPost.mockResolvedValueOnce({ status: 500 }); - - const [delivery] = await dispatcher.dispatch({ - event_type: 'pool.assets_locked', - event_id: 'evt_payload', - data: { important: 'value' }, - }); - - const persisted = await deliveryRepo.findById(delivery.id); - expect(persisted.payload.data.important).toBe('value'); - - mockAxiosPost.mockResolvedValueOnce({ status: 200 }); - const retried = await dispatcher.attempt(delivery.id); - expect(retried.status).toBe('success'); - const body = JSON.parse(mockAxiosPost.mock.calls[1][1]); - expect(body.data.important).toBe('value'); - }); -}); diff --git a/test/webhookEvents.test.js b/test/webhookEvents.test.js deleted file mode 100644 index c174077..0000000 --- a/test/webhookEvents.test.js +++ /dev/null @@ -1,38 +0,0 @@ -'use strict'; - -const events = require('../src/services/webhookEvents'); - -describe('webhook event registry', () => { - test('pool.assets_locked is a known event', () => { - expect(events.isKnownEvent('pool.assets_locked')).toBe(true); - }); - - test('unknown event types are rejected', () => { - expect(events.isKnownEvent('something.random')).toBe(false); - }); - - test('isValidSubscription accepts a non-empty array of known events', () => { - expect(events.isValidSubscription(['pool.assets_locked'])).toBe(true); - expect(events.isValidSubscription(['pool.assets_locked', 'pool.closed'])).toBe(true); - }); - - test('isValidSubscription accepts wildcard', () => { - expect(events.isValidSubscription(['*'])).toBe(true); - }); - - test('isValidSubscription rejects empty arrays and bad inputs', () => { - expect(events.isValidSubscription([])).toBe(false); - expect(events.isValidSubscription(null)).toBe(false); - expect(events.isValidSubscription(['nope'])).toBe(false); - }); - - test('matchesSubscription exact match', () => { - expect(events.matchesSubscription(['pool.assets_locked'], 'pool.assets_locked')).toBe(true); - expect(events.matchesSubscription(['pool.closed'], 'pool.assets_locked')).toBe(false); - }); - - test('matchesSubscription wildcard subscribes to all', () => { - expect(events.matchesSubscription(['*'], 'pool.assets_locked')).toBe(true); - expect(events.matchesSubscription(['*'], 'price.alert')).toBe(true); - }); -}); diff --git a/test/webhookRepository.test.js b/test/webhookRepository.test.js deleted file mode 100644 index 02e78e4..0000000 --- a/test/webhookRepository.test.js +++ /dev/null @@ -1,97 +0,0 @@ -'use strict'; - -const { createCacheMock } = require('./helpers/cacheMock'); - -const mockHelper = createCacheMock(); -const { reset } = mockHelper; - -jest.mock('../src/services/cache', () => mockHelper.cacheMock); -jest.mock('../src/logger', () => ({ - info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), -})); - -const webhookRepo = require('../src/repositories/webhookRepository'); -const events = require('../src/services/webhookEvents'); - -beforeEach(() => reset()); - -describe('webhookRepository', () => { - test('create persists a webhook with generated id and active=true', async () => { - const w = await webhookRepo.create({ - url: 'https://example.com/hook', - events: ['pool.assets_locked'], - secret: 'whsec_aaaaaaaaaaaaaaaa', - }); - expect(w.id).toMatch(/^wh_/); - expect(w.active).toBe(true); - expect(w.events).toEqual(['pool.assets_locked']); - }); - - test('findById returns the stored webhook', async () => { - const created = await webhookRepo.create({ - url: 'https://example.com/hook', - events: ['*'], - secret: 'whsec_aaaaaaaaaaaaaaaa', - }); - const found = await webhookRepo.findById(created.id); - expect(found.id).toBe(created.id); - }); - - test('findById returns null when missing', async () => { - expect(await webhookRepo.findById('wh_nope')).toBeNull(); - }); - - test('listAll returns every created webhook, unpaginated', async () => { - await webhookRepo.create({ url: 'https://a.com', events: ['*'], secret: 'whsec_aaaaaaaaaaaaaaaa' }); - await webhookRepo.create({ url: 'https://b.com', events: ['*'], secret: 'whsec_bbbbbbbbbbbbbbbb' }); - const all = await webhookRepo.listAll(); - expect(all).toHaveLength(2); - }); - - test('list returns a paginated { webhooks, total } page (#131)', async () => { - await webhookRepo.create({ url: 'https://a.com', events: ['*'], secret: 'whsec_aaaaaaaaaaaaaaaa' }); - await webhookRepo.create({ url: 'https://b.com', events: ['*'], secret: 'whsec_bbbbbbbbbbbbbbbb' }); - await webhookRepo.create({ url: 'https://c.com', events: ['*'], secret: 'whsec_cccccccccccccccc' }); - - const page1 = await webhookRepo.list(1, 2); - expect(page1.webhooks).toHaveLength(2); - expect(page1.total).toBe(3); - - const page2 = await webhookRepo.list(2, 2); - expect(page2.webhooks).toHaveLength(1); - expect(page2.total).toBe(3); - - // No overlap between pages. - const page1Ids = page1.webhooks.map((w) => w.id); - const page2Ids = page2.webhooks.map((w) => w.id); - expect(page1Ids.some((id) => page2Ids.includes(id))).toBe(false); - }); - - test('update merges patch and bumps updated_at', async () => { - const w = await webhookRepo.create({ url: 'https://a.com', events: ['*'], secret: 'whsec_aaaaaaaaaaaaaaaa' }); - const updated = await webhookRepo.update(w.id, { active: false }); - expect(updated.active).toBe(false); - expect(updated.created_at).toBe(w.created_at); - expect(updated.updated_at >= w.updated_at).toBe(true); - }); - - test('remove deletes and returns the previous record', async () => { - const w = await webhookRepo.create({ url: 'https://a.com', events: ['*'], secret: 'whsec_aaaaaaaaaaaaaaaa' }); - const removed = await webhookRepo.remove(w.id); - expect(removed.id).toBe(w.id); - expect(await webhookRepo.listAll()).toHaveLength(0); - }); - - test('listActiveForEvent filters by subscription and active flag', async () => { - const a = await webhookRepo.create({ url: 'https://a.com', events: ['pool.assets_locked'], secret: 'whsec_aaaaaaaaaaaaaaaa' }); - const b = await webhookRepo.create({ url: 'https://b.com', events: ['pool.closed'], secret: 'whsec_bbbbbbbbbbbbbbbb' }); - const c = await webhookRepo.create({ url: 'https://c.com', events: ['*'], secret: 'whsec_cccccccccccccccc' }); - await webhookRepo.update(c.id, { active: false }); - - const result = await webhookRepo.listActiveForEvent('pool.assets_locked', events.matchesSubscription); - const ids = result.map((w) => w.id).sort(); - expect(ids).toEqual([a.id].sort()); - expect(ids).not.toContain(b.id); - expect(ids).not.toContain(c.id); - }); -}); diff --git a/test/webhookSignature.test.js b/test/webhookSignature.test.js deleted file mode 100644 index a599f50..0000000 --- a/test/webhookSignature.test.js +++ /dev/null @@ -1,117 +0,0 @@ -'use strict'; - -const http = require('http'); -const signature = require('../src/services/webhookSignature'); -const { - buildSignatureHeaders, - sendSignedRequest, - signPayload, - verifySignature, -} = require('../src/services/webhook'); - -describe('webhook signature', () => { - const secret = 'whsec_test_supersecret_value'; - const body = JSON.stringify({ event: 'pool.assets_locked', amount: 42 }); - - test('sign produces a sha256= prefixed hex string', () => { - const sig = signature.sign(secret, body); - expect(sig).toMatch(/^sha256=[0-9a-f]{64}$/); - }); - - test('verify returns true for matching body and signature', () => { - const sig = signature.sign(secret, body); - expect(signature.verify(secret, body, sig)).toBe(true); - }); - - test('verify returns false when body is tampered', () => { - const sig = signature.sign(secret, body); - const tampered = body.replace('42', '43'); - expect(signature.verify(secret, tampered, sig)).toBe(false); - }); - - test('verify returns false when signature is tampered', () => { - const sig = signature.sign(secret, body); - const tampered = sig.replace(/.$/, sig.endsWith('a') ? 'b' : 'a'); - expect(signature.verify(secret, body, tampered)).toBe(false); - }); - - test('verify returns false when signature lacks the prefix', () => { - const sig = signature.sign(secret, body).replace('sha256=', ''); - expect(signature.verify(secret, body, sig)).toBe(false); - }); - - test('verify returns false for empty/wrong secret', () => { - const sig = signature.sign(secret, body); - expect(signature.verify('other_secret_value', body, sig)).toBe(false); - }); - - test('generateSecret produces a whsec_-prefixed token', () => { - const s = signature.generateSecret(); - expect(s).toMatch(/^whsec_[0-9a-f]{64}$/); - }); - - test('sign accepts objects by stringifying them', () => { - const obj = { a: 1, b: 'two' }; - const sigFromObj = signature.sign(secret, obj); - const sigFromStr = signature.sign(secret, JSON.stringify(obj)); - expect(sigFromObj).toBe(sigFromStr); - }); -}); - -describe('webhook signatures', () => { - test('signs and verifies payloads with timestamped HMAC-SHA256', () => { - const payload = { event: 'airdrop.completed', airdrop_id: 'drop-1' }; - const timestamp = 1782345600000; - const signature = `sha256=${signPayload('whsec_testsecret', payload, timestamp)}`; - - expect(verifySignature('whsec_testsecret', payload, signature, timestamp)).toBe(true); - expect(verifySignature('wrong_secret', payload, signature, timestamp)).toBe(false); - }); - - test('builds SmartDrop signature and timestamp headers', () => { - const headers = buildSignatureHeaders('whsec_testsecret', { event: 'ping' }, 1782345600000); - - expect(headers['X-SmartDrop-Signature']).toMatch(/^sha256=[a-f0-9]{64}$/); - expect(headers['X-SmartDrop-Timestamp']).toBe('1782345600000'); - }); - - test('mock HTTP server receives signed request', async () => { - let captured = null; - const server = http.createServer((req, res) => { - const chunks = []; - req.on('data', (chunk) => chunks.push(chunk)); - req.on('end', () => { - captured = { - headers: req.headers, - body: Buffer.concat(chunks).toString('utf8'), - }; - res.statusCode = 204; - res.end(); - }); - }); - - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const { port } = server.address(); - - try { - const payload = { event: 'ping', timestamp: '2026-06-25T00:00:00.000Z' }; - const result = await sendSignedRequest( - `http://127.0.0.1:${port}/hook`, - 'whsec_testsecret', - payload - ); - - expect(result).toMatchObject({ ok: true, status: 204 }); - expect(captured.headers['x-smartdrop-signature']).toMatch(/^sha256=[a-f0-9]{64}$/); - expect(captured.headers['x-smartdrop-timestamp']).toBeDefined(); - expect(verifySignature( - 'whsec_testsecret', - captured.body, - captured.headers['x-smartdrop-signature'], - captured.headers['x-smartdrop-timestamp'] - )).toBe(true); - } finally { - await new Promise((resolve) => server.close(resolve)); - } - }); -}); diff --git a/test/webhooks.routes.test.js b/test/webhooks.routes.test.js deleted file mode 100644 index 4a17d0f..0000000 --- a/test/webhooks.routes.test.js +++ /dev/null @@ -1,199 +0,0 @@ -'use strict'; - -const express = require('express'); -const request = require('supertest'); -const { createCacheMock } = require('./helpers/cacheMock'); - -const mockHelper = createCacheMock(); -const { reset } = mockHelper; - -jest.mock('../src/services/cache', () => mockHelper.cacheMock); -jest.mock('../src/logger', () => ({ - info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), -})); - -const mockAxiosPost = jest.fn(); -jest.mock('axios', () => ({ post: (...args) => mockAxiosPost(...args) })); - -const webhooksRouter = require('../src/routes/webhooks'); - -function buildApp() { - const app = express(); - app.use(express.json()); - app.use('/api/v1', webhooksRouter); - return app; -} - -beforeEach(() => { - reset(); - mockAxiosPost.mockReset(); -}); - -describe('POST /api/v1/webhooks', () => { - const app = buildApp(); - - test('creates a webhook with valid input', async () => { - const res = await request(app) - .post('/api/v1/webhooks') - .send({ - url: 'https://example.com/hook', - events: ['pool.assets_locked'], - secret: 'whsec_user_supplied_secret_long_enough', - }); - expect(res.status).toBe(201); - expect(res.body.id).toMatch(/^wh_/); - expect(res.body.events).toEqual(['pool.assets_locked']); - expect(res.body.secret).toBe('whsec_user_supplied_secret_long_enough'); - expect(res.body.secret_warning).toMatch(/Store this secret/); - }); - - test('generates a secret when none provided', async () => { - const res = await request(app) - .post('/api/v1/webhooks') - .send({ url: 'https://example.com/hook', events: ['*'] }); - expect(res.status).toBe(201); - expect(res.body.secret).toMatch(/^whsec_[0-9a-f]+$/); - }); - - test('rejects invalid url', async () => { - const res = await request(app) - .post('/api/v1/webhooks') - .send({ url: 'not-a-url', events: ['*'] }); - expect(res.status).toBe(400); - }); - - test('rejects unknown event types', async () => { - const res = await request(app) - .post('/api/v1/webhooks') - .send({ url: 'https://example.com/hook', events: ['totally.fake'] }); - expect(res.status).toBe(400); - }); - - test('rejects too-short secret', async () => { - const res = await request(app) - .post('/api/v1/webhooks') - .send({ url: 'https://example.com/hook', events: ['*'], secret: 'short' }); - expect(res.status).toBe(400); - }); -}); - -describe('GET /api/v1/webhooks', () => { - const app = buildApp(); - - test('lists registered webhooks without leaking full secrets', async () => { - await request(app).post('/api/v1/webhooks').send({ - url: 'https://a.com', events: ['*'], secret: 'whsec_aaaaaaaaaaaaaaaa', - }); - const res = await request(app).get('/api/v1/webhooks'); - expect(res.status).toBe(200); - // Canonical pagination envelope (#131): array under `data`, not `webhooks`. - expect(res.body.data).toHaveLength(1); - expect(res.body.data[0].secret_preview).toMatch(/^whsec_/); - expect(res.body.data[0]).not.toHaveProperty('secret'); - expect(res.body.pagination).toMatchObject({ page: 1, limit: 20, total: 1 }); - }); - - test('paginates registered webhooks with page/limit query params (#131)', async () => { - for (const letter of ['a', 'b', 'c']) { - await request(app).post('/api/v1/webhooks').send({ - url: `https://${letter}.com`, events: ['*'], secret: `whsec_${letter}${letter}${letter}${letter}${letter}${letter}${letter}${letter}${letter}${letter}${letter}${letter}${letter}${letter}${letter}${letter}`, - }); - } - - const res = await request(app).get('/api/v1/webhooks?page=1&limit=2'); - expect(res.status).toBe(200); - expect(res.body.data).toHaveLength(2); - expect(res.body.pagination).toMatchObject({ - page: 1, - limit: 2, - total: 3, - total_pages: 2, - has_next: true, - has_prev: false, - }); - }); -}); - -describe('GET /api/v1/webhooks/:id', () => { - const app = buildApp(); - - test('returns 404 for unknown webhook', async () => { - const res = await request(app).get('/api/v1/webhooks/wh_nope'); - expect(res.status).toBe(404); - }); -}); - -describe('DELETE /api/v1/webhooks/:id', () => { - const app = buildApp(); - - test('deletes a registered webhook', async () => { - const created = await request(app).post('/api/v1/webhooks').send({ - url: 'https://example.com/hook', events: ['*'], secret: 'whsec_aaaaaaaaaaaaaaaa', - }); - const del = await request(app).delete(`/api/v1/webhooks/${created.body.id}`); - expect(del.status).toBe(200); - expect(del.body.deleted).toBe(true); - - const list = await request(app).get('/api/v1/webhooks'); - expect(list.body.data).toHaveLength(0); - }); - - test('returns 404 when deleting unknown id', async () => { - const res = await request(app).delete('/api/v1/webhooks/wh_nope'); - expect(res.status).toBe(404); - }); -}); - -describe('PATCH /api/v1/webhooks/:id', () => { - const app = buildApp(); - - test('updates active status', async () => { - const created = await request(app).post('/api/v1/webhooks').send({ - url: 'https://example.com/hook', events: ['*'], secret: 'whsec_aaaaaaaaaaaaaaaa', - }); - const res = await request(app) - .patch(`/api/v1/webhooks/${created.body.id}`) - .send({ active: false }); - expect(res.status).toBe(200); - expect(res.body.active).toBe(false); - }); -}); - -describe('POST /api/v1/webhooks/:id/test', () => { - const app = buildApp(); - - test('sends a test delivery and returns delivery summary', async () => { - const created = await request(app).post('/api/v1/webhooks').send({ - url: 'https://example.com/hook', events: ['*'], secret: 'whsec_aaaaaaaaaaaaaaaa', - }); - mockAxiosPost.mockResolvedValueOnce({ status: 200 }); - - const res = await request(app).post(`/api/v1/webhooks/${created.body.id}/test`); - expect(res.status).toBe(202); - expect(res.body.delivery_id).toMatch(/^dlv_/); - expect(res.body.status).toBe('success'); - expect(mockAxiosPost).toHaveBeenCalledTimes(1); - }); - - test('returns 404 when webhook does not exist', async () => { - const res = await request(app).post('/api/v1/webhooks/wh_nope/test'); - expect(res.status).toBe(404); - }); -}); - -describe('GET /api/v1/webhooks/:id/deliveries', () => { - const app = buildApp(); - - test('lists deliveries for a webhook', async () => { - const created = await request(app).post('/api/v1/webhooks').send({ - url: 'https://example.com/hook', events: ['*'], secret: 'whsec_aaaaaaaaaaaaaaaa', - }); - mockAxiosPost.mockResolvedValue({ status: 200 }); - await request(app).post(`/api/v1/webhooks/${created.body.id}/test`); - - const res = await request(app).get(`/api/v1/webhooks/${created.body.id}/deliveries`); - expect(res.status).toBe(200); - expect(Array.isArray(res.body.deliveries)).toBe(true); - expect(res.body.deliveries.length).toBeGreaterThan(0); - }); -}); diff --git a/test/webhooks.test.js b/test/webhooks.test.js deleted file mode 100644 index eebe53b..0000000 --- a/test/webhooks.test.js +++ /dev/null @@ -1,139 +0,0 @@ -'use strict'; - -const mockStore = new Map(); -const mockSets = new Map(); - -const mockRedis = { - smembers: jest.fn(async (key) => [...(mockSets.get(key) || [])]), - sadd: jest.fn(async (key, val) => { - if (!mockSets.has(key)) mockSets.set(key, new Set()); - mockSets.get(key).add(val); - }), - srem: jest.fn(async (key, val) => { - mockSets.get(key)?.delete(val); - }), -}; - -const mockSendSignedRequest = jest.fn(); - -jest.mock('../src/services/cache', () => ({ - getClient: () => mockRedis, - get: jest.fn(async (key) => { - const value = mockStore.get(key); - return value !== undefined ? JSON.parse(JSON.stringify(value)) : null; - }), - set: jest.fn(async (key, value) => { - mockStore.set(key, JSON.parse(JSON.stringify(value))); - }), - del: jest.fn(async (key) => { - mockStore.delete(key); - }), -})); - -jest.mock('../src/services/webhook', () => ({ - sendSignedRequest: mockSendSignedRequest, -})); - -jest.mock('../src/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -const webhooks = require('../src/services/webhooks'); - -beforeEach(() => { - mockStore.clear(); - mockSets.clear(); - mockSendSignedRequest.mockReset(); - mockSendSignedRequest.mockResolvedValue({ ok: true, status: 204, duration_ms: 1 }); - jest.clearAllMocks(); -}); - -function endpoint(overrides = {}) { - return { - id: 'wh_test', - url: 'https://example.com/hook', - events: ['airdrop.completed'], - secret: 'whsec_testsecret', - active: true, - ...overrides, - }; -} - -describe('webhook endpoint service', () => { - test('creates, lists, and removes endpoints without exposing raw secrets', async () => { - const created = await webhooks.createEndpoint({ - url: 'https://example.com/hook', - events: ['airdrop.completed'], - secret: 'whsec_testsecret', - }); - - expect(created.id).toMatch(/^wh_/); - expect(created.secret).toBeUndefined(); - expect(created.secret_preview).toBe('whse...cret'); - - await expect(webhooks.listEndpoints()).resolves.toEqual([ - expect.objectContaining({ id: created.id, secret_preview: 'whse...cret' }), - ]); - - await expect(webhooks.removeEndpoint(created.id)).resolves.toMatchObject({ id: created.id }); - await expect(webhooks.listEndpoints()).resolves.toEqual([]); - }); - - test('records a successful delivery after one retry', async () => { - const transport = jest.fn() - .mockResolvedValueOnce({ ok: false, status: 500, duration_ms: 12 }) - .mockResolvedValueOnce({ ok: true, status: 204, duration_ms: 8 }); - const wait = jest.fn(async () => {}); - - const delivery = await webhooks.processDelivery( - endpoint(), - 'airdrop.completed', - { event: 'airdrop.completed' }, - { transport, sleep: wait } - ); - - expect(delivery.status).toBe('delivered'); - expect(delivery.attempt_count).toBe(2); - expect(delivery.attempts[0]).toMatchObject({ response_code: 500, status: 'failed' }); - expect(delivery.attempts[1]).toMatchObject({ response_code: 204, status: 'delivered' }); - expect(wait).toHaveBeenCalledTimes(1); - }); - - test('moves delivery to dead letter after max attempts', async () => { - const transport = jest.fn(async () => ({ ok: false, status: 503, duration_ms: 5 })); - const wait = jest.fn(async () => {}); - - const delivery = await webhooks.processDelivery( - endpoint(), - 'airdrop.failed', - { event: 'airdrop.failed' }, - { transport, sleep: wait, maxAttempts: 3 } - ); - - expect(delivery.status).toBe('dead_letter'); - expect(delivery.attempt_count).toBe(3); - expect(wait).toHaveBeenCalledTimes(2); - expect([...mockSets.get('webhooks:dead_letters')]).toContain(delivery.id); - }); - - test('deliverEvent queues subscribed endpoints only', async () => { - const created = await webhooks.createEndpoint({ - url: 'https://example.com/hook', - events: ['recipient.claimed'], - secret: 'whsec_testsecret', - }); - await webhooks.createEndpoint({ - url: 'https://example.com/other', - events: ['airdrop.failed'], - secret: 'whsec_testsecret', - }); - - const deliveries = await webhooks.deliverEvent('recipient.claimed', { event: 'recipient.claimed' }); - - expect(deliveries).toHaveLength(1); - expect(deliveries[0].endpoint_id).toBe(created.id); - }); -}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..aba29b0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "esModuleInterop": true, + "isolatedModules": true, + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "noFallthroughCasesInSwitch": false + } +}